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
28 changes: 20 additions & 8 deletions src/module/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ async function loadAuthOptions(context: SchemaContext) {
.map(([key, value]) => [key, value as string]),
)
const userConfig = await loadUserAuthConfig(configFile, isProduction, alias, context.nuxt.options.runtimeConfig, context.nuxt.options.rootDir)
if (!userConfig)
return null

const extendedConfig: { plugins?: BetterAuthPlugin[] } = {}
await context.nuxt.callHook('better-auth:config:extend', extendedConfig)
Expand All @@ -98,8 +100,25 @@ export async function setupBetterAuthSchema(

const context: SchemaContext = { nuxt, serverConfigPath }

// Registered before generation so NuxtHub still resolves the schema file already
// on disk when generation below is skipped. resolveHubSchemaPath is a plain
// filesystem lookup, so it does not care whether this run produced the file.
const nuxtWithHubHooks = nuxt as Nuxt & { hook: (name: string, cb: (arg: { paths: string[], dialect: string }) => void) => void }
nuxtWithHubHooks.hook('hub:db:schema:extend', ({ paths, dialect: hookDialect }) => {
const schemaPath = resolveHubSchemaPath(nuxt.options.buildDir, nuxt.options.rootDir, hookDialect)
if (schemaPath)
paths.unshift(schemaPath)
})

try {
const { userConfig, plugins } = await loadAuthOptions(context)
const authConfig = await loadAuthOptions(context)
// A config that failed to load carries none of the user's additionalFields or
// plugin columns. Regenerating from it would overwrite a correct schema file
// with a core-tables-only one, so leave whatever is already on disk alone.
if (!authConfig)
return

const { userConfig, plugins } = authConfig
const userHasSecondaryStorage = userConfig.secondaryStorage != null
const secondaryStorageResolution = resolveSchemaSecondaryStorageInjection(hubSecondaryStorage, userHasSecondaryStorage, !nuxt.options.dev)
if (secondaryStorageResolution.error)
Expand Down Expand Up @@ -144,13 +163,6 @@ export async function setupBetterAuthSchema(
addTemplate({ filename: `better-auth/schema.${dialect}.mjs`, getContents: () => schemaCode, write: true })

consola.info(`Generated ${dialect} schema (.ts + .mjs)`)

const nuxtWithHubHooks = nuxt as Nuxt & { hook: (name: string, cb: (arg: { paths: string[], dialect: string }) => void) => void }
nuxtWithHubHooks.hook('hub:db:schema:extend', ({ paths, dialect: hookDialect }) => {
const schemaPath = resolveHubSchemaPath(nuxt.options.buildDir, nuxt.options.rootDir, hookDialect)
if (schemaPath)
paths.unshift(schemaPath)
})
}
catch (error) {
const isProduction = !nuxt.options.dev
Expand Down
22 changes: 17 additions & 5 deletions src/schema-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,13 +78,24 @@ declare global {
var __nuxtBetterAuthDefineServerAuth: RuntimeDefineServerAuthFn | undefined
}

const NO_DEFAULT_EXPORT_MESSAGE = '[@nuxtjs/better-auth] auth.config.ts does not export default. Expected: export default defineServerAuth(...)'
const SCHEMA_NOT_REGENERATED_MESSAGE = 'The schema was not regenerated and any existing generated schema file was left unchanged.'

/**
* Loads the user's `auth.config.ts`.
*
* Returns `null` when the config could not be loaded and `throwOnError` is
* false. `null` is distinct from an empty-but-valid config (`{}`): callers must
* treat it as "no config available" and leave anything derived from a previous
* successful load alone, rather than regenerating it from nothing.
*/
export async function loadUserAuthConfig(
configPath: string,
throwOnError = false,
alias?: Record<string, string>,
runtimeConfig: unknown = {},
rootDir?: string,
): Promise<Partial<BetterAuthOptions>> {
): Promise<Partial<BetterAuthOptions> | null> {
const { createJiti } = await import('jiti')
const { defineServerAuth: runtimeDefineServerAuth } = await import('./runtime/config')
const jiti = createJiti(import.meta.url, { interopDefault: true, moduleCache: false, alias })
Expand All @@ -106,18 +117,19 @@ export async function loadUserAuthConfig(
if (typeof configFn === 'function') {
return configFn({ runtimeConfig, db: null })
}
consola.warn('[@nuxtjs/better-auth] auth.config.ts does not export default. Expected: export default defineServerAuth(...)')
if (throwOnError) {
consola.warn(NO_DEFAULT_EXPORT_MESSAGE)
throw new Error('auth.config.ts must export default defineServerAuth(...)')
}
return {}
consola.error(`${NO_DEFAULT_EXPORT_MESSAGE}. ${SCHEMA_NOT_REGENERATED_MESSAGE}`)
return null
}
catch (error) {
if (throwOnError) {
throw new Error(`Failed to load auth config: ${error instanceof Error ? error.message : error}`)
}
consola.error('[@nuxtjs/better-auth] Failed to load auth config for schema generation. Schema may be incomplete:', error)
return {}
consola.error(`[@nuxtjs/better-auth] Failed to load auth config for schema generation. ${SCHEMA_NOT_REGENERATED_MESSAGE}`, error)
return null
}
finally {
const sharedDefineServerAuth = schemaGlobals.__nuxtBetterAuthDefineServerAuth
Expand Down
147 changes: 141 additions & 6 deletions test/schema-generator.test.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,107 @@
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
import type { Nuxt } from '@nuxt/schema'
import type { ConsolaInstance } from 'consola'
import type { BetterAuthModuleOptions } from '../src/runtime/config'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { runWithNuxtContext } from '@nuxt/kit'
import { getAuthTables } from 'better-auth/db'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'
import { setupBetterAuthSchema } from '../src/module/schema'
import { buildSchemaExportCode } from '../src/module/templates'
import { defineClientAuth, defineServerAuth } from '../src/runtime/config'
import { generateDrizzleSchema, loadUserAuthConfig } from '../src/schema-generator'

const TEST_DIR = join(import.meta.dirname, '.test-configs')
const projectDirs: string[] = []

beforeAll(() => {
if (!existsSync(TEST_DIR))
mkdirSync(TEST_DIR, { recursive: true })
})
afterEach(() => {
for (const dir of projectDirs.splice(0, projectDirs.length))
rmSync(dir, { recursive: true, force: true })
})
afterAll(() => {
if (existsSync(TEST_DIR))
rmSync(TEST_DIR, { recursive: true })
})

const silentConsola = {
info: () => {},
warn: () => {},
error: () => {},
success: () => {},
} as unknown as ConsolaInstance

/** A schema file from an earlier, successful generation. */
const PREVIOUS_SCHEMA = 'export const user = sqliteTable("user", { customField: text("customField") })\n'

const BROKEN_CONFIG = `import './does-not-exist'\n\nexport default defineServerAuth({ plugins: [] })`
const NO_DEFAULT_EXPORT_CONFIG = `export const auth = defineServerAuth({ plugins: [] })`
const ADDITIONAL_FIELDS_CONFIG = `export default defineServerAuth({ user: { additionalFields: { customField: { type: 'string', required: false } } } })`

/**
* A throwaway Nuxt project on disk, carrying only what `setupBetterAuthSchema`
* reads: a sqlite hub dialect, a build dir to write into, and an auth config
* whose contents each test chooses.
*/
function createSchemaProject(options: { dev: boolean, config: string }) {
const rootDir = mkdtempSync(join(tmpdir(), 'nuxt-better-auth-project-'))
projectDirs.push(rootDir)

const buildDir = join(rootDir, '.nuxt')
const serverDir = join(rootDir, 'server')
mkdirSync(buildDir, { recursive: true })
mkdirSync(serverDir, { recursive: true })

const serverConfigPath = join(serverDir, 'auth.config')
writeFileSync(`${serverConfigPath}.ts`, options.config)

const hooks = new Map<string, (payload: { paths: string[], dialect: string }) => void>()

const nuxt = {
options: {
dev: options.dev,
alias: {},
runtimeConfig: {},
rootDir,
buildDir,
build: { templates: [] },
hub: { db: 'sqlite' },
},
callHook: async () => {},
hook: (name: string, cb: (payload: { paths: string[], dialect: string }) => void) => {
hooks.set(name, cb)
},
} as unknown as Nuxt

const schemaPath = join(buildDir, 'better-auth', 'schema.sqlite.ts')

const run = () => runWithNuxtContext(nuxt, () => setupBetterAuthSchema(
nuxt,
serverConfigPath,
{} as BetterAuthModuleOptions,
silentConsola,
undefined,
))

const writeExistingSchema = (contents: string) => {
mkdirSync(join(buildDir, 'better-auth'), { recursive: true })
writeFileSync(schemaPath, contents)
}

/** Fires NuxtHub's `hub:db:schema:extend` hook and returns the paths it collected. */
const collectHubSchemaPaths = () => {
const paths: string[] = []
hooks.get('hub:db:schema:extend')?.({ paths, dialect: 'sqlite' })
return paths
}

return { run, schemaPath, writeExistingSchema, collectHubSchemaPaths }
}

describe('generateDrizzleSchema', () => {
it('singular table names by default', async () => {
const schema = await generateDrizzleSchema({}, 'sqlite')
Expand Down Expand Up @@ -112,9 +197,9 @@ describe('getAuthTables with secondaryStorage', () => {
})

describe('loadUserAuthConfig', () => {
it('returns empty object for non-existent file (dev mode)', async () => {
it('returns null for non-existent file (dev mode)', async () => {
const result = await loadUserAuthConfig(join(TEST_DIR, 'nonexistent.ts'), false)
expect(result).toEqual({})
expect(result).toBeNull()
})

it('throws for non-existent file when throwOnError=true', async () => {
Expand All @@ -128,11 +213,11 @@ describe('loadUserAuthConfig', () => {
expect(result).toEqual({ plugins: [] })
})

it('warns and returns empty for non-function export (dev mode)', async () => {
it('warns and returns null for non-function export (dev mode)', async () => {
const configPath = join(TEST_DIR, 'invalid-config.ts')
writeFileSync(configPath, `export default { notAFunction: true }`)
const result = await loadUserAuthConfig(configPath, false)
expect(result).toEqual({})
expect(result).toBeNull()
})

it('throws for non-function export when throwOnError=true', async () => {
Expand Down Expand Up @@ -205,6 +290,56 @@ describe('loadUserAuthConfig', () => {
})
})

describe.each([
['throws on load', BROKEN_CONFIG],
['has no default export', NO_DEFAULT_EXPORT_CONFIG],
])('setupBetterAuthSchema in dev mode when the auth config %s', (_label, config) => {
it('writes no schema file', async () => {
const project = createSchemaProject({ dev: true, config })

await project.run()

expect(existsSync(project.schemaPath)).toBe(false)
})

it('leaves a previously generated schema file untouched', async () => {
const project = createSchemaProject({ dev: true, config })
project.writeExistingSchema(PREVIOUS_SCHEMA)

await project.run()

expect(readFileSync(project.schemaPath, 'utf8')).toBe(PREVIOUS_SCHEMA)
})

it('still points NuxtHub at the previously generated schema file', async () => {
const project = createSchemaProject({ dev: true, config })
project.writeExistingSchema(PREVIOUS_SCHEMA)

await project.run()

expect(project.collectHubSchemaPaths()).toEqual([project.schemaPath])
})
})

describe('setupBetterAuthSchema when the auth config fails to load', () => {
it('rejects in production mode instead of writing a schema file', async () => {
const project = createSchemaProject({ dev: false, config: BROKEN_CONFIG })

await expect(project.run()).rejects.toThrow('Failed to load auth config')
expect(existsSync(project.schemaPath)).toBe(false)
})
})

describe('setupBetterAuthSchema when the auth config loads', () => {
it('writes a schema carrying the configured additionalFields', async () => {
const project = createSchemaProject({ dev: true, config: ADDITIONAL_FIELDS_CONFIG })

await project.run()

expect(readFileSync(project.schemaPath, 'utf8')).toContain('customField')
})
})

describe('defineServerAuth', () => {
it('accepts object syntax and returns config factory', () => {
const factory = defineServerAuth({ appName: 'Test', emailAndPassword: { enabled: true } })
Expand Down
Loading