diff --git a/src/module/templates.ts b/src/module/templates.ts index e3cecf8e..f88d0c96 100644 --- a/src/module/templates.ts +++ b/src/module/templates.ts @@ -72,7 +72,8 @@ function getCloudflareContext(event) { function resolveHyperdrive(event) { const cloudflareEnv = getCloudflareContext(event)?.env - return cloudflareEnv?.POSTGRES || process.env.POSTGRES || globalThis.__env__?.POSTGRES || globalThis.POSTGRES + const runtimeEnv = event?.req?.runtime?.cloudflare?.env + return cloudflareEnv?.POSTGRES || runtimeEnv?.POSTGRES || process.env.POSTGRES || globalThis.__env__?.POSTGRES || globalThis.POSTGRES } function scheduleCleanup(event, cleanup) { @@ -114,6 +115,11 @@ function registerClientCleanup(event, client) { response.once('close', cleanup) } +// Hyperdrive clients belong to one request; any other database is shared by the whole process. +export function isRequestScopedDatabase(event) { + return Boolean(resolveHyperdrive(event)?.connectionString) +} + export function createDatabase(event) { const hyperdrive = resolveHyperdrive(event) if (!hyperdrive?.connectionString) @@ -152,11 +158,13 @@ import * as schema from './schema.${input.hubDialect}.mjs' import { drizzleAdapter } from 'better-auth/adapters/drizzle' const rawDialect = '${input.hubDialect}' const dialect = rawDialect === 'postgresql' ? 'pg' : rawDialect +export function isRequestScopedDatabase() { return false } export function createDatabase() { return drizzleAdapter(db, { provider: dialect, schema, usePlural: ${input.usePlural}, camelCase: ${input.camelCase} }) } export { db }` } - return `export function createDatabase() { return undefined } + return `export function isRequestScopedDatabase() { return false } +export function createDatabase() { return undefined } export const db = undefined` } diff --git a/src/module/type-templates.ts b/src/module/type-templates.ts index 64a8c4ca..9822acc0 100644 --- a/src/module/type-templates.ts +++ b/src/module/type-templates.ts @@ -70,6 +70,7 @@ type AvailableRouterMethod<_ReqT extends NitroFetchRequest> = 'get' | 'head' | ' declare module '#auth/database' { import type { BetterAuthOptions } from 'better-auth' export function createDatabase(event?: import('${h3TypesPath}').H3Event): BetterAuthOptions['database'] + export function isRequestScopedDatabase(event?: import('${h3TypesPath}').H3Event): boolean export const db: ${hasHubDb ? `typeof import('@nuxthub/db')['db']` : 'undefined'} } `, diff --git a/src/runtime/server/utils/auth.ts b/src/runtime/server/utils/auth.ts index 27e32798..cebe0c80 100644 --- a/src/runtime/server/utils/auth.ts +++ b/src/runtime/server/utils/auth.ts @@ -1,7 +1,7 @@ import type { BetterAuthOptions, BetterAuthPlugin } from 'better-auth' import type { ServerEvent } from '../internal/nitro-compat' import { betterAuth, env } from 'better-auth' -import { createDatabase, db } from '#auth/database' +import { createDatabase, db, isRequestScopedDatabase } from '#auth/database' import createServerAuth from '#auth/server' import type { DerivedAuthSecretState } from '../internal/auth-secret' import { getConfiguredAuthSecret, getDerivedAuthSecret, hasExplicitAuthSecret, prepareAuthSecret } from '../internal/auth-secret' @@ -365,6 +365,18 @@ export function serverAuth(event?: ServerEvent): AuthInstance { if (requestContext?.[requestAuthKey]) return requestContext[requestAuthKey] + // Only a request-scoped database (a Hyperdrive client) forces a fresh instance per request. Otherwise + // a cached instance is returned before building the user config, which reruns every plugin factory. + const requestScopedDatabase = (isRequestScopedDatabase as (event?: ServerEvent) => boolean)(event) + if (!requestScopedDatabase) { + const cached = _authCache.get(cacheKey) + if (cached) { + if (requestContext) + requestContext[requestAuthKey] = cached + return cached + } + } + userConfig ??= createServerAuth({ runtimeConfig, db, requestOrigin }) as UserAuthConfig // Explicit secrets keep their existing precedence; Nuxt's appSecret only fills the gap (4.6+). @@ -404,15 +416,7 @@ export function serverAuth(event?: ServerEvent): AuthInstance { : userConfig.rateLimit // An instance without the derived secret must not outlive the request that needed it. - const cacheable = !database && !derivedSecretMissing - if (cacheable) { - const cached = _authCache.get(cacheKey) - if (cached) { - if (requestContext) - requestContext[requestAuthKey] = cached - return cached - } - } + const cacheable = !requestScopedDatabase && !derivedSecretMissing const authOptions = { ...userConfig, diff --git a/src/runtime/server/virtual-modules.d.ts b/src/runtime/server/virtual-modules.d.ts index d177c990..ef27a595 100644 --- a/src/runtime/server/virtual-modules.d.ts +++ b/src/runtime/server/virtual-modules.d.ts @@ -1,6 +1,7 @@ declare module '#auth/database' { export const db: any export function createDatabase(...args: any[]): any + export function isRequestScopedDatabase(...args: any[]): boolean } declare module '#imports' { diff --git a/test/cases/plugins-type-inference/virtual-modules.d.ts b/test/cases/plugins-type-inference/virtual-modules.d.ts index 632996af..71cecf88 100644 --- a/test/cases/plugins-type-inference/virtual-modules.d.ts +++ b/test/cases/plugins-type-inference/virtual-modules.d.ts @@ -1,6 +1,7 @@ declare module '#auth/database' { export const db: any export function createDatabase(...args: any[]): any + export function isRequestScopedDatabase(...args: any[]): boolean } declare module '#better-auth/app-secret' { diff --git a/test/database-scope.test.ts b/test/database-scope.test.ts new file mode 100644 index 00000000..a4fbeb53 --- /dev/null +++ b/test/database-scope.test.ts @@ -0,0 +1,43 @@ +import { runInNewContext } from 'node:vm' +import { describe, expect, it, vi } from 'vitest' +import { buildDatabaseCode } from '../src/module/templates' + +function loadDatabase() { + const postgres = vi.fn(() => ({})) + const code = buildDatabaseCode({ provider: 'nuxthub', hubDialect: 'postgresql', usePlural: false, camelCase: false }) + .replace(/^import .*$/gm, '') + .replace(/^export /gm, '') + const database = runInNewContext(`${code}\n;({ isRequestScopedDatabase, createDatabase })`, { + process: { env: {} }, + db: {}, + schema: {}, + postgres, + drizzle: (options: unknown) => options, + drizzleAdapter: (database: unknown) => database, + }) + return { ...database, postgres } +} + +describe('generated database request scope', () => { + it.each(['context', 'runtime'])('recognizes Hyperdrive in the %s binding location', (location) => { + const { isRequestScopedDatabase, createDatabase, postgres } = loadDatabase() + const cloudflare = { env: { POSTGRES: { connectionString: 'postgres://hyperdrive/test' } } } + const event = location === 'context' + ? { context: { cloudflare } } + : { context: {}, req: { runtime: { cloudflare } } } + + expect(isRequestScopedDatabase(event)).toBe(true) + const first = createDatabase(event) + expect(createDatabase(event)).toBe(first) + expect(postgres).toHaveBeenCalledExactlyOnceWith('postgres://hyperdrive/test', expect.objectContaining({ prepare: false, max: 1 })) + }) + + it('keeps database access process-wide without a Hyperdrive connection string', () => { + const { isRequestScopedDatabase, createDatabase, postgres } = loadDatabase() + const event = { req: { runtime: { cloudflare: { env: {} } } } } + expect(isRequestScopedDatabase(event)).toBe(false) + expect(isRequestScopedDatabase()).toBe(false) + createDatabase(event) + expect(postgres).not.toHaveBeenCalled() + }) +}) diff --git a/test/get-base-url.test.ts b/test/get-base-url.test.ts index c66b0a3b..9152ea4c 100644 --- a/test/get-base-url.test.ts +++ b/test/get-base-url.test.ts @@ -10,7 +10,7 @@ const runtimeConfig = { } const createServerAuthMock = vi.fn() -vi.mock('#auth/database', () => ({ createDatabase: () => undefined, db: {} })) +vi.mock('#auth/database', () => ({ createDatabase: () => undefined, db: {}, isRequestScopedDatabase: () => false })) vi.mock('#auth/server', () => ({ default: createServerAuthMock })) vi.mock('better-auth', () => ({ betterAuth: (options: BetterAuthOptions) => ({ options }), diff --git a/test/server-auth-app-secret.test.ts b/test/server-auth-app-secret.test.ts index 29b7e769..bc75fb74 100644 --- a/test/server-auth-app-secret.test.ts +++ b/test/server-auth-app-secret.test.ts @@ -9,6 +9,7 @@ const appSecret = { available: true } vi.mock('#auth/database', () => ({ createDatabase: () => undefined, db: undefined, + isRequestScopedDatabase: () => false, })) vi.mock('#auth/server', () => ({ diff --git a/test/server-auth-database-cache.test.ts b/test/server-auth-database-cache.test.ts index badfa49a..49c0a7f6 100644 --- a/test/server-auth-database-cache.test.ts +++ b/test/server-auth-database-cache.test.ts @@ -3,12 +3,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const betterAuthMock = vi.fn() const createDatabaseMock = vi.fn() +const isRequestScopedDatabaseMock = vi.fn() const createServerAuthMock = vi.fn() const useRuntimeConfigMock = vi.fn() vi.mock('#auth/database', () => ({ createDatabase: createDatabaseMock, db: { query: {} }, + isRequestScopedDatabase: isRequestScopedDatabaseMock, })) vi.mock('#auth/server', () => ({ @@ -58,6 +60,7 @@ describe('serverAuth database cache and secret validation', () => { createServerAuthMock.mockReturnValue({ trustedOrigins: undefined, }) + isRequestScopedDatabaseMock.mockReturnValue(false) betterAuthMock.mockImplementation((options: Record) => ({ options, @@ -291,11 +294,28 @@ describe('serverAuth database cache and secret validation', () => { expect(betterAuthMock).toHaveBeenCalledTimes(1) }) - it('creates a fresh auth instance for each request when a database adapter is active', async () => { + it('shares one auth instance across requests when the database adapter is process-wide', async () => { createDatabaseMock.mockReturnValue({ kind: 'database-adapter' }) const { serverAuth } = await import('../src/runtime/server/utils/auth') + const first = serverAuth(createEvent()) + const second = serverAuth(createEvent()) + const third = serverAuth() + + expect(first).toBe(second) + expect(third).toBe(first) + expect(createServerAuthMock).toHaveBeenCalledTimes(1) + expect(createDatabaseMock).toHaveBeenCalledTimes(1) + expect(betterAuthMock).toHaveBeenCalledTimes(1) + }) + + it('creates a fresh auth instance for each request when the database is request-scoped', async () => { + createDatabaseMock.mockReturnValue({ kind: 'database-adapter' }) + isRequestScopedDatabaseMock.mockReturnValue(true) + + const { serverAuth } = await import('../src/runtime/server/utils/auth') + const first = serverAuth(createEvent()) const second = serverAuth(createEvent()) @@ -304,8 +324,9 @@ describe('serverAuth database cache and secret validation', () => { expect(betterAuthMock).toHaveBeenCalledTimes(2) }) - it('creates a fresh auth instance without request context when a database adapter is active', async () => { + it('creates a fresh auth instance without request context when the database is request-scoped', async () => { createDatabaseMock.mockReturnValue({ kind: 'database-adapter' }) + isRequestScopedDatabaseMock.mockReturnValue(true) const { serverAuth } = await import('../src/runtime/server/utils/auth') @@ -326,7 +347,8 @@ describe('serverAuth database cache and secret validation', () => { const second = serverAuth() expect(first).toBe(second) - expect(createDatabaseMock).toHaveBeenCalledTimes(2) + expect(createServerAuthMock).toHaveBeenCalledTimes(1) + expect(createDatabaseMock).toHaveBeenCalledTimes(1) expect(betterAuthMock).toHaveBeenCalledTimes(1) }) diff --git a/test/server-auth-startup.test.ts b/test/server-auth-startup.test.ts index e2e77fa6..eba51080 100644 --- a/test/server-auth-startup.test.ts +++ b/test/server-auth-startup.test.ts @@ -24,6 +24,7 @@ vi.mock('../src/runtime/server/internal/nitro-compat', () => ({ vi.mock('#auth/database', () => ({ createDatabase: () => undefined, db: undefined, + isRequestScopedDatabase: () => false, })) vi.mock('#auth/server', () => ({