diff --git a/.changeset/tidy-schema-check.md b/.changeset/tidy-schema-check.md new file mode 100644 index 0000000000..6b4ca7ab66 --- /dev/null +++ b/.changeset/tidy-schema-check.md @@ -0,0 +1,5 @@ +--- +"auth": patch +--- + +Add read-only `check` and `check schema` commands to validate the configured adapter schema on demand. diff --git a/docs/content/docs/concepts/cli.mdx b/docs/content/docs/concepts/cli.mdx index 5e8b967ca7..8d02c01ed8 100644 --- a/docs/content/docs/concepts/cli.mdx +++ b/docs/content/docs/concepts/cli.mdx @@ -1,10 +1,10 @@ --- icon: conceptsCLI title: CLI -description: Learn about the Better Auth CLI commands for generating and migrating database schemas, creating initial admins, initializing projects, generating secret keys, and gathering diagnostic info. +description: Learn about the Better Auth CLI commands for generating, migrating, and checking database schemas, creating initial admins, initializing projects, generating secret keys, and gathering diagnostic info. --- -Better Auth comes with a built-in CLI to help you manage the database schemas, create initial admin users, initialize your project, generate a secret key for your application, and gather diagnostic information about your setup. +Better Auth comes with a built-in CLI to help you manage and check database schemas, create initial admin users, initialize your project, generate a secret key for your application, and gather diagnostic information about your setup. ## Generate @@ -71,6 +71,31 @@ npx auth@latest migrate Set `database.schemaName` to select the schema explicitly. Otherwise, the migrate command uses the connection's `search_path`. See the [PostgreSQL adapter documentation](/docs/adapters/postgresql#use-a-non-default-schema) for configuration details. +## Check + +The `check` command runs all available checks. Run it whenever you want to verify your Better Auth setup, manually or in automation: + +```bash title="Terminal" +npx auth@latest check --config ./src/auth.ts +``` + +### Schema + +The `check schema` command verifies that your configured schema can hold what Better Auth writes. It does not create schema files or apply migrations. + +```bash title="Terminal" +npx auth@latest check schema --config ./src/auth.ts +``` + +The schema check reads the live database for Kysely, the configured schema object for Drizzle, and the generated client model for Prisma. Drizzle and Prisma checks do not confirm that migrations have been applied to the database. If validation is unavailable for your adapter, the command fails instead of silently skipping it. It also runs when runtime validation is disabled with `advanced.database.validateSchema: false`. + +### Options + +Both `check` and `check schema` accept: + +* `-c, --cwd` - The working directory. Defaults to the current directory. +* `--config` - The path to your Better Auth config file. By default, the CLI searches the usual config locations. + ## Create Admin The `create-admin` command creates an initial admin user through your configured Better Auth instance. It requires the Admin plugin and a persistent database, and it uses the same server-side `auth.api.createUser` path as the Admin plugin so passwords are hashed and database hooks still run. diff --git a/docs/content/docs/concepts/database.mdx b/docs/content/docs/concepts/database.mdx index 749d798bf3..3e35d92996 100644 --- a/docs/content/docs/concepts/database.mdx +++ b/docs/content/docs/concepts/database.mdx @@ -125,7 +125,7 @@ await runMigrations(); ## Schema Validation -During initialization, Better Auth compares the schema with the tables it writes and reports missing tables, missing columns, and required columns it never fills, together with their fixes. Errors appear through your configured logger without waiting for an authentication request. Requests await the same check and fail if the schema does not match; validation does not automatically stop your server or build. +During initialization, Better Auth compares the schema with the tables it writes and reports missing tables, missing columns, and required columns it never fills, together with their fixes. Errors appear through your configured logger without waiting for an authentication request. Requests await the same check and fail if the schema does not match; validation does not automatically stop your server or build. Run [`npx auth@latest check schema`](/docs/concepts/cli#schema) whenever you want to check explicitly without making an authentication request. Validation is enabled by default, including in production, and caches a clean result or mismatch per adapter instance. Programmatic migrations invalidate cached checks for the same database instance. Requests waiting on an invalidated check await the new result. Restart after applying schema changes with other tools. Kysely reads live database metadata and needs database access during initialization. Drizzle checks the configured schema object and Prisma checks the generated client's data model, without querying the database. These local checks cannot detect migrations that were not applied to the database. The compact `prisma-client` model omits nullability, so required unwritten columns are reported by `auth generate` instead. Custom adapters without a registered check are not validated. diff --git a/e2e/adapter/test/drizzle-v2-relations-adapter/adapter.drizzle.schema-check.test.ts b/e2e/adapter/test/drizzle-v2-relations-adapter/adapter.drizzle.schema-check.test.ts index 1e94df306b..b4dff1fd5d 100644 --- a/e2e/adapter/test/drizzle-v2-relations-adapter/adapter.drizzle.schema-check.test.ts +++ b/e2e/adapter/test/drizzle-v2-relations-adapter/adapter.drizzle.schema-check.test.ts @@ -1,6 +1,7 @@ import type { BetterAuthOptions } from "@better-auth/core"; import { getExpectedSchema, + runtimeSchemaCheckFor, schemaCheckFor, } from "@better-auth/core/db/internal"; import { drizzleAdapter } from "@better-auth/drizzle-adapter/relations-v2"; @@ -136,7 +137,7 @@ describe("relations-v2 schema validation", () => { }); }); - it("does not register a check when validation is disabled", ({ + it("keeps explicit validation when runtime validation is disabled", async ({ onTestFinished, }) => { const client = new Database(":memory:"); @@ -147,6 +148,11 @@ describe("relations-v2 schema validation", () => { const adapter = drizzleAdapter(db, { provider: "sqlite" })({ advanced: { database: { validateSchema: false } }, }); - expect(schemaCheckFor(adapter)).toBeUndefined(); + const explicitCheck = schemaCheckFor(adapter); + expect(runtimeSchemaCheckFor(adapter)).toBeUndefined(); + expect(explicitCheck).toBeTypeOf("function"); + await expect(explicitCheck!()).rejects.toMatchObject({ + code: "SCHEMA_MISMATCH", + }); }); }); diff --git a/packages/better-auth/src/context/create-context.ts b/packages/better-auth/src/context/create-context.ts index 38c2d0b1f0..8b550d2af5 100644 --- a/packages/better-auth/src/context/create-context.ts +++ b/packages/better-auth/src/context/create-context.ts @@ -6,7 +6,10 @@ import type { import { getBetterAuthVersion } from "@better-auth/core/context"; import { getAuthTables } from "@better-auth/core/db"; import type { DBAdapter } from "@better-auth/core/db/adapter"; -import { schemaCheckFor } from "@better-auth/core/db/internal"; +import { + runtimeSchemaCheckFor, + schemaCheckFor, +} from "@better-auth/core/db/internal"; import { createLogger, env, isProduction, isTest } from "@better-auth/core/env"; import { BetterAuthError } from "@better-auth/core/error"; import type { OAuthProvider } from "@better-auth/core/oauth2"; @@ -434,6 +437,7 @@ Most of the features of Better Auth will not work correctly.`, await initOrPromise; } - ctx.checkSchema = schemaCheckFor(ctx.adapter); + ctx.checkSchema = runtimeSchemaCheckFor(ctx.adapter); + ctx.explicitSchemaCheck = schemaCheckFor(ctx.adapter); return ctx as unknown as AuthContext; } diff --git a/packages/better-auth/src/context/init-minimal.test.ts b/packages/better-auth/src/context/init-minimal.test.ts index 01fbb41c6d..20b934cee5 100644 --- a/packages/better-auth/src/context/init-minimal.test.ts +++ b/packages/better-auth/src/context/init-minimal.test.ts @@ -56,5 +56,21 @@ describe("init-minimal (without Kysely)", () => { database, }); expect(res.checkSchema).toBe(check); + expect(res.explicitSchemaCheck).toBe(check); + }); + + it("exposes an explicit check without enabling runtime validation", async () => { + const check = () => undefined; + const database = (options: BetterAuthOptions) => { + const adapter = memoryAdapter({})(options); + registerSchemaCheck(adapter, check, { runtimeEnabled: false }); + return adapter; + }; + const res = await initMinimal({ + baseURL: "http://localhost:3000", + database, + }); + expect(res.checkSchema).toBeUndefined(); + expect(res.explicitSchemaCheck).toBe(check); }); }); diff --git a/packages/cli/src/commands/check.test.ts b/packages/cli/src/commands/check.test.ts new file mode 100644 index 0000000000..0f715f1f23 --- /dev/null +++ b/packages/cli/src/commands/check.test.ts @@ -0,0 +1,180 @@ +import { fileURLToPath } from "node:url"; +import type { AuthContext } from "@better-auth/core"; +import { + createSchemaCheck, + registerSchemaCheck, + SchemaMismatchError, + schemaCheckFor, +} from "@better-auth/core/db/internal"; +import { test as baseTest, describe, expect, vi } from "vitest"; +import { getAuth } from "../utils/get-config"; +import { check } from "./check"; + +vi.mock("../utils/get-config", () => ({ getAuth: vi.fn() })); + +const test = baseTest.extend("runCheck", ({}, { onCleanup }) => { + process.exitCode = undefined; + const exit = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("command exited"); + }); + onCleanup(() => { + process.exitCode = undefined; + vi.mocked(getAuth).mockReset(); + }); + return async (...args: string[]) => { + await expect(check.parseAsync(args, { from: "user" })).rejects.toThrow( + "command exited", + ); + return exit.mock.calls[0]?.[0]; + }; +}); + +function useAdapter(adapter: object) { + vi.mocked(getAuth).mockResolvedValue({ + options: {}, + $context: Promise.resolve({ + adapter, + explicitSchemaCheck: schemaCheckFor(adapter), + } as AuthContext), + }); +} + +describe("check", () => { + test("rejects a cwd that does not exist", async ({ expect, runCheck }) => { + const cwd = fileURLToPath(new URL("missing-directory", import.meta.url)); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + + const exitCode = await runCheck("--cwd", cwd); + + expect(getAuth).not.toHaveBeenCalled(); + expect(error).toHaveBeenCalledWith( + `The directory "${cwd}" does not exist.`, + ); + expect(exitCode).toBe(2); + }); + + test("rejects a cwd that is not a directory", async ({ + expect, + runCheck, + }) => { + const cwd = fileURLToPath(import.meta.url); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + + const exitCode = await runCheck("--cwd", cwd); + + expect(getAuth).not.toHaveBeenCalled(); + expect(error).toHaveBeenCalledWith(`The path "${cwd}" is not a directory.`); + expect(exitCode).toBe(2); + }); + + test("passes when the configured adapter schema matches", async ({ + expect, + runCheck, + }) => { + const adapter = { id: "kysely" }; + const find = vi.fn(async () => []); + registerSchemaCheck(adapter, createSchemaCheck(find, "database")); + useAdapter(adapter); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + + const exitCode = await runCheck(); + + expect(find).toHaveBeenCalledOnce(); + expect(log).toHaveBeenCalledWith( + "Schema check passed against the live database.", + ); + expect(exitCode).toBe(0); + }); + + test("checks the schema through the schema subcommand", async ({ + expect, + runCheck, + }) => { + const adapter = { id: "kysely" }; + const find = vi.fn(async () => []); + registerSchemaCheck(adapter, createSchemaCheck(find, "database")); + useAdapter(adapter); + vi.spyOn(console, "log").mockImplementation(() => {}); + + const exitCode = await runCheck("schema"); + + expect(find).toHaveBeenCalledOnce(); + expect(exitCode).toBe(0); + }); + + test("checks explicitly even when runtime validation is disabled", async ({ + expect, + runCheck, + }) => { + const adapter = { id: "drizzle" }; + const find = vi.fn(async () => []); + registerSchemaCheck(adapter, createSchemaCheck(find, "drizzle"), { + runtimeEnabled: false, + }); + useAdapter(adapter); + vi.spyOn(console, "log").mockImplementation(() => {}); + + const exitCode = await runCheck(); + + expect(find).toHaveBeenCalledOnce(); + expect(exitCode).toBe(0); + }); + + test("fails on a schema mismatch", async ({ expect, runCheck }) => { + const adapter = { id: "kysely" }; + registerSchemaCheck( + adapter, + createSchemaCheck( + async () => [{ kind: "missing-table", table: "user" } as const], + "database", + ), + ); + useAdapter(adapter); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + + const exitCode = await runCheck(); + + expect(error.mock.calls[0]?.[0]).toBe( + new SchemaMismatchError( + [{ kind: "missing-table", table: "user" }], + "database", + ).message, + ); + expect(exitCode).toBe(1); + }); + + test("fails rather than passing an unchecked adapter", async ({ + expect, + runCheck, + }) => { + useAdapter({ id: "custom" }); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + + const exitCode = await runCheck(); + + expect(error).toHaveBeenCalledWith( + 'Schema validation is not available for adapter "custom".', + ); + expect(exitCode).toBe(2); + }); + + test("does not print database credentials when validation cannot run", async ({ + expect, + runCheck, + }) => { + const adapter = { id: "kysely" }; + registerSchemaCheck( + adapter, + createSchemaCheck(async () => { + throw new Error("postgres://user:secret@private-db/database"); + }, "database"), + ); + useAdapter(adapter); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + + const exitCode = await runCheck(); + + expect(JSON.stringify(error.mock.calls)).not.toContain("secret"); + expect(exitCode).toBe(2); + }); +}); diff --git a/packages/cli/src/commands/check.ts b/packages/cli/src/commands/check.ts new file mode 100644 index 0000000000..f530d70fcb --- /dev/null +++ b/packages/cli/src/commands/check.ts @@ -0,0 +1,120 @@ +import type { Stats } from "node:fs"; +import { stat } from "node:fs/promises"; +import path from "node:path"; +import { Command } from "commander"; +import * as z from "zod"; +import { getAuth } from "../utils/get-config"; + +const sourceLabels = { + database: "the live database", + drizzle: "the configured Drizzle schema", + prisma: "the generated Prisma client model", +} as const; + +async function checkSchemaAction(input: unknown): Promise { + const options = z + .object({ + cwd: z.string(), + config: z.string().optional(), + }) + .parse(input); + const cwd = path.resolve(options.cwd); + let cwdStat: Stats; + try { + cwdStat = await stat(cwd); + } catch (error) { + const { code } = error as NodeJS.ErrnoException; + if (code === "ENOENT") { + console.error(`The directory "${cwd}" does not exist.`); + } else { + console.error( + `Could not access the directory "${cwd}"${code ? ` (${code}).` : "."}`, + ); + } + process.exitCode = 2; + return; + } + if (!cwdStat.isDirectory()) { + console.error(`The path "${cwd}" is not a directory.`); + process.exitCode = 2; + return; + } + + let auth: Awaited>; + try { + auth = await getAuth({ + cwd, + configPath: options.config, + shouldThrowOnError: true, + }); + } catch { + console.error("Could not load the Better Auth configuration."); + process.exitCode = 2; + return; + } + if (!auth) { + console.error( + "No Better Auth configuration found. Pass --config to select one.", + ); + process.exitCode = 2; + return; + } + + try { + const context = await auth.$context; + const check = context.explicitSchemaCheck; + if (!check) { + console.error( + `Schema validation is not available for adapter "${context.adapter.id}".`, + ); + process.exitCode = 2; + return; + } + await check(); + const source = check.source + ? sourceLabels[check.source] + : `adapter "${context.adapter.id}"`; + console.log(`Schema check passed against ${source}.`); + process.exitCode = 0; + } catch (error) { + if ( + error instanceof Error && + "code" in error && + error.code === "SCHEMA_MISMATCH" + ) { + console.error(error.message); + process.exitCode = 1; + return; + } + console.error( + "Could not validate the schema. Check the auth configuration and database connection.", + ); + process.exitCode = 2; + } +} + +async function runSchemaCheck(options: unknown): Promise { + await checkSchemaAction(options); + process.exit(typeof process.exitCode === "number" ? process.exitCode : 0); +} + +export const check = new Command("check") + .description("Run all available Better Auth checks") + .option( + "-c, --cwd ", + "the working directory. defaults to the current directory.", + process.cwd(), + ) + .option( + "--config ", + "the path to the Better Auth configuration file.", + ) + .action(runSchemaCheck); + +check + .command("schema") + .description( + "Check that the configured schema can hold what Better Auth writes", + ) + .configureHelp({ showGlobalOptions: true }) + .action((_options, command) => runSchemaCheck(command.optsWithGlobals())); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 069fa571f1..b74eb6d3b0 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -3,6 +3,7 @@ import { BetterAuthError } from "@better-auth/core/error"; import { Command } from "commander"; import { ai } from "./commands/ai"; +import { check } from "./commands/check"; import { createAdmin } from "./commands/create-admin"; import { generate } from "./commands/generate"; import { info } from "./commands/info"; @@ -25,6 +26,7 @@ async function main() { program .addCommand(ai) .addCommand(createAdmin) + .addCommand(check) .addCommand(init) .addCommand(migrate) .addCommand(generate) diff --git a/packages/cli/src/utils/get-config.ts b/packages/cli/src/utils/get-config.ts index f0fca05a22..7f673dcf66 100644 --- a/packages/cli/src/utils/get-config.ts +++ b/packages/cli/src/utils/get-config.ts @@ -4,7 +4,7 @@ import path from "node:path"; import babelPresetReact from "@babel/preset-react"; // @ts-expect-error import babelPresetTypeScript from "@babel/preset-typescript"; -import type { BetterAuthOptions } from "@better-auth/core"; +import type { AuthContext, BetterAuthOptions } from "@better-auth/core"; import { BetterAuthError } from "@better-auth/core/error"; import { loadConfig } from "c12"; import type { TsConfigResult } from "get-tsconfig"; @@ -358,18 +358,25 @@ function resolvesMissingOutputModule( ); } -export async function getConfig({ - cwd, - configPath, - outputPath, - shouldThrowOnError = false, -}: { +type ConfigInput = { cwd: string; configPath?: string; /** Schema output used to recover an exact first-run self-import. */ outputPath?: string; shouldThrowOnError?: boolean; -}) { +}; + +type LoadedAuth = { + options?: BetterAuthOptions; + $context?: Promise; +}; + +async function getLoadedConfig({ + cwd, + configPath, + outputPath, + shouldThrowOnError = false, +}: ConfigInput): Promise { const fail = (message: string, error?: unknown): never => { if (shouldThrowOnError) throw error instanceof Error ? error : new Error(message); @@ -384,7 +391,7 @@ export async function getConfig({ : undefined; const load = async (configFile: string) => { const loadOnce = () => - loadConfig<{ options?: BetterAuthOptions }>({ + loadConfig({ configFile, dotenv: { fileName: [".env", ".env.local"] }, jitiOptions: jitiOptions(cwd), @@ -437,11 +444,11 @@ export async function getConfig({ `Couldn't read your auth config in ${resolvedPath}. Make sure to default export your auth instance or to export as a variable named auth.`, ); } - return options; + return config; } for (const possiblePath of possiblePaths) { - let config: { options?: BetterAuthOptions }; + let config: LoadedAuth; try { ({ config } = await load(possiblePath)); } catch (e) { @@ -455,7 +462,7 @@ export async function getConfig({ "Couldn't read your auth config. Make sure to default export your auth instance or to export as a variable named auth.", ); } - return config.options; + return config; } return null; } catch (e) { @@ -463,3 +470,16 @@ export async function getConfig({ return fail("Couldn't read your auth config.", e); } } + +export async function getConfig(input: ConfigInput) { + return (await getLoadedConfig(input))?.options ?? null; +} + +export async function getAuth(input: ConfigInput) { + const auth = await getLoadedConfig(input); + if (!auth) return null; + if (!auth.$context) { + throw new Error("The auth config must export a Better Auth instance."); + } + return { options: auth.options, $context: auth.$context }; +} diff --git a/packages/cli/test/check.test.ts b/packages/cli/test/check.test.ts new file mode 100644 index 0000000000..d174e0fc1b --- /dev/null +++ b/packages/cli/test/check.test.ts @@ -0,0 +1,74 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { beforeAll, describe, expect, it } from "vitest"; +import { getAuth } from "../src/utils/get-config"; +import { auth as fixtureAuth } from "./fixtures/check-schema-auth"; +import { cliPath } from "./utils"; + +const execute = promisify(execFile); +const configPath = fileURLToPath( + new URL("./fixtures/check-schema-auth.ts", import.meta.url), +); + +beforeAll(() => { + if (!fs.existsSync(cliPath)) { + throw new Error( + `CLI binary not found at "${cliPath}". Run "pnpm --filter auth build" before running this test.`, + ); + } +}); + +describe("check", () => { + it("exposes an explicit check from the loaded auth config", async () => { + const auth = await getAuth({ + cwd: path.dirname(configPath), + configPath, + shouldThrowOnError: true, + }); + assert(auth); + expect((await fixtureAuth.$context).adapter.id).toBe("kysely"); + + const context = await auth.$context; + expect(context.adapter.id).toBe("kysely"); + expect(context.checkSchema).toBeUndefined(); + expect(context.explicitSchemaCheck).toBeTypeOf("function"); + await expect(context.explicitSchemaCheck?.()).rejects.toMatchObject({ + code: "SCHEMA_MISMATCH", + }); + }); + + it.for([ + { command: "check", args: ["check"] }, + { command: "check schema", args: ["check", "schema"] }, + ])("$command exits with status 1 when the schema does not match", async ({ + args, + }) => { + const result = execute( + process.execPath, + [ + cliPath, + ...args, + "--cwd", + path.dirname(configPath), + "--config", + configPath, + ], + { + cwd: path.dirname(configPath), + env: { + ...process.env, + BETTER_AUTH_TELEMETRY_DISABLED: "true", + }, + }, + ); + + await expect(result).rejects.toMatchObject({ + code: 1, + stderr: expect.stringContaining("Database schema mismatch"), + }); + }); +}); diff --git a/packages/cli/test/fixtures/check-schema-auth.ts b/packages/cli/test/fixtures/check-schema-auth.ts new file mode 100644 index 0000000000..9d7ba58602 --- /dev/null +++ b/packages/cli/test/fixtures/check-schema-auth.ts @@ -0,0 +1,14 @@ +import { DatabaseSync } from "node:sqlite"; +import { betterAuth } from "better-auth"; + +export const auth = betterAuth({ + baseURL: "https://auth.example.com", + database: new DatabaseSync(":memory:"), + secret: "check-schema-test-secret-at-least-32-characters", + logger: { disabled: true }, + advanced: { + database: { + validateSchema: false, + }, + }, +}); diff --git a/packages/core/src/context/transaction.ts b/packages/core/src/context/transaction.ts index f91b85ea5e..93ffe02f1f 100644 --- a/packages/core/src/context/transaction.ts +++ b/packages/core/src/context/transaction.ts @@ -1,7 +1,7 @@ import type { AsyncLocalStorage } from "@better-auth/core/async_hooks"; import { getAsyncLocalStorage } from "@better-auth/core/async_hooks"; import type { DBAdapter, DBTransactionAdapter } from "../db/adapter"; -import { schemaCheckFor } from "../db/schema-check"; +import { runtimeSchemaCheckFor } from "../db/schema-check"; import type { BetterAuthOptions } from "../types"; import { __getBetterAuthGlobal } from "./global"; @@ -117,7 +117,7 @@ export const runWithTransaction = async < } // Settle the schema verdict before this transaction holds the // connection a single-connection store would need for the lookup. - const pendingSchemaCheck = schemaCheckFor(adapter)?.(); + const pendingSchemaCheck = runtimeSchemaCheckFor(adapter)?.(); if (pendingSchemaCheck) await pendingSchemaCheck; const pendingHooks: Array<() => Promise> = []; let result: Awaited; diff --git a/packages/core/src/db/internal.ts b/packages/core/src/db/internal.ts index 0dc54e4ed5..4dde9e55b6 100644 --- a/packages/core/src/db/internal.ts +++ b/packages/core/src/db/internal.ts @@ -15,6 +15,7 @@ export { createSchemaCheck, invalidateSchemaChecks, registerSchemaCheck, + runtimeSchemaCheckFor, type SchemaCheck, schemaCheckFor, } from "./schema-check"; diff --git a/packages/core/src/db/schema-check.test.ts b/packages/core/src/db/schema-check.test.ts index 1ffe4dc192..302628dab4 100644 --- a/packages/core/src/db/schema-check.test.ts +++ b/packages/core/src/db/schema-check.test.ts @@ -4,6 +4,7 @@ import { createSchemaCheck, invalidateSchemaChecks, registerSchemaCheck, + runtimeSchemaCheckFor, schemaCheckFor, } from "./schema-check"; import type { SchemaFinding } from "./schema-diff"; @@ -171,4 +172,15 @@ describe("schema check registry", () => { expect(schemaCheckFor(adapter)).toBe(check); expect(schemaCheckFor({})).toBeUndefined(); }); + + it("keeps an explicit check available when runtime validation is disabled", async () => { + const adapter = {}; + const find = vi.fn(async () => []); + const check = createSchemaCheck(find, "database"); + registerSchemaCheck(adapter, check, { runtimeEnabled: false }); + + expect(runtimeSchemaCheckFor(adapter)).toBeUndefined(); + await schemaCheckFor(adapter)?.(); + expect(find).toHaveBeenCalledOnce(); + }); }); diff --git a/packages/core/src/db/schema-check.ts b/packages/core/src/db/schema-check.ts index e5d4b1b407..8466170694 100644 --- a/packages/core/src/db/schema-check.ts +++ b/packages/core/src/db/schema-check.ts @@ -5,7 +5,7 @@ * adapter authors once the registration and lifecycle contracts are stabilized. */ -import type { BetterAuthOptions } from "../types"; +import type { Awaitable, BetterAuthOptions } from "../types"; import type { SchemaFinding, SchemaSource } from "./schema-diff"; import { SchemaMismatchError } from "./schema-diff"; @@ -21,11 +21,20 @@ export function checksSchema(options: BetterAuthOptions): boolean { * Resolves when the schema can hold what Better Auth writes. Returns nothing * once that is known and the database schema revision is unchanged. */ -export type SchemaCheck = () => Promise | undefined; +export type SchemaCheck = (() => Promise | undefined) & { + source?: SchemaSource; +}; -const schemaChecks = new WeakMap(); +const schemaChecks = new WeakMap< + object, + { check: SchemaCheck; runtimeEnabled: boolean } +>(); const schemaRevisions = new WeakMap(); +type SchemaCheckRegistrationOptions = { + runtimeEnabled?: boolean; +}; + /** Invalidates cached checks after Better Auth changes this database's schema. */ export function invalidateSchemaChecks(database: object): void { const revision = schemaRevisions.get(database); @@ -36,15 +45,30 @@ export function invalidateSchemaChecks(database: object): void { * Attaches a check to the adapter it verifies. The adapter object itself is * left untouched, so this works for adapters Better Auth does not own. */ -export function registerSchemaCheck(adapter: object, check: SchemaCheck): void { - schemaChecks.set(adapter, check); +export function registerSchemaCheck( + adapter: object, + check: SchemaCheck, + options: SchemaCheckRegistrationOptions = {}, +): void { + const runtimeEnabled = options.runtimeEnabled ?? true; + schemaChecks.set(adapter, { check, runtimeEnabled }); } /** - * The check registered for an adapter, if its store is checked at all. + * The registered check, regardless of whether runtime validation is enabled. */ export function schemaCheckFor(adapter: object): SchemaCheck | undefined { - return schemaChecks.get(adapter); + return schemaChecks.get(adapter)?.check; +} + +/** + * The check used by runtime paths, when runtime validation is enabled. + */ +export function runtimeSchemaCheckFor( + adapter: object, +): SchemaCheck | undefined { + const registration = schemaChecks.get(adapter); + return registration?.runtimeEnabled ? registration.check : undefined; } /** @@ -69,7 +93,7 @@ export function schemaCheckFor(adapter: object): SchemaCheck | undefined { * ``` */ export function createSchemaCheck( - find: () => Promise, + find: () => Awaitable, source: SchemaSource, database?: object, ): SchemaCheck { @@ -81,7 +105,9 @@ export function createSchemaCheck( let checkedRevision = revision?.value; let clean = false; let verdict: Promise | undefined; - return function checkSchema(): Promise | undefined { + const checkSchema: SchemaCheck = function checkSchema(): + | Promise + | undefined { const currentRevision = revision?.value; if (checkedRevision !== currentRevision) { checkedRevision = currentRevision; @@ -104,4 +130,6 @@ export function createSchemaCheck( }, )); }; + checkSchema.source = source; + return checkSchema; } diff --git a/packages/core/src/types/context.ts b/packages/core/src/types/context.ts index 3d39949f0f..d4d85e3bb7 100644 --- a/packages/core/src/types/context.ts +++ b/packages/core/src/types/context.ts @@ -428,6 +428,10 @@ export type AuthContext = * Absent when the check is disabled or the adapter registers none. */ checkSchema?: SchemaCheck | undefined; + /** + * Available for an explicit check even when runtime validation is disabled. + */ + explicitSchemaCheck?: SchemaCheck | undefined; internalAdapter: InternalAdapter; createAuthCookie: CreateCookieGetterFn; secret: string; diff --git a/packages/drizzle-adapter/src/drizzle-adapter.ts b/packages/drizzle-adapter/src/drizzle-adapter.ts index cbf1b84dbd..48b46f1942 100644 --- a/packages/drizzle-adapter/src/drizzle-adapter.ts +++ b/packages/drizzle-adapter/src/drizzle-adapter.ts @@ -1259,20 +1259,18 @@ export const drizzleAdapter = (db: DB, config: DrizzleAdapterConfig) => { return (options: BetterAuthOptions): DBAdapter => { lazyOptions = options; const instance = adapter(options); - if (checksSchema(options)) { - registerSchemaCheck( - instance, - createSchemaCheck( - async () => - findDrizzleSchemaProblems( - config.schema ?? db._?.fullSchema ?? {}, - options, - config.usePlural, - ), - "drizzle", + const schemaCheck = createSchemaCheck( + () => + findDrizzleSchemaProblems( + config.schema ?? db._?.fullSchema ?? {}, + options, + config.usePlural, ), - ); - } + "drizzle", + ); + registerSchemaCheck(instance, schemaCheck, { + runtimeEnabled: checksSchema(options), + }); return instance; }; }; diff --git a/packages/drizzle-adapter/src/relations-v2/index.ts b/packages/drizzle-adapter/src/relations-v2/index.ts index 5a28f3edf6..7e19dcb109 100644 --- a/packages/drizzle-adapter/src/relations-v2/index.ts +++ b/packages/drizzle-adapter/src/relations-v2/index.ts @@ -1193,26 +1193,24 @@ export const drizzleAdapter = (db: DB, config: DrizzleAdapterConfig) => { return (options: BetterAuthOptions): DBAdapter => { lazyOptions = options; const instance = adapter(options); - if (checksSchema(options)) { - registerSchemaCheck( - instance, - createSchemaCheck(async () => { - const relations: Record = - db._?.relations ?? {}; - const schema = { - ...db._?.fullSchema, - ...Object.fromEntries( - Object.entries(relations).map(([name, relation]) => [ - name, - relation.table, - ]), - ), - ...config.schema, - }; - return findDrizzleSchemaProblems(schema, options, config.usePlural); - }, "drizzle"), - ); - } + const schemaCheck = createSchemaCheck(() => { + const relations: Record = + db._?.relations ?? {}; + const schema = { + ...db._?.fullSchema, + ...Object.fromEntries( + Object.entries(relations).map(([name, relation]) => [ + name, + relation.table, + ]), + ), + ...config.schema, + }; + return findDrizzleSchemaProblems(schema, options, config.usePlural); + }, "drizzle"); + registerSchemaCheck(instance, schemaCheck, { + runtimeEnabled: checksSchema(options), + }); return instance; }; }; diff --git a/packages/drizzle-adapter/src/schema-check.test.ts b/packages/drizzle-adapter/src/schema-check.test.ts index 39ded550b0..b9a1066bc3 100644 --- a/packages/drizzle-adapter/src/schema-check.test.ts +++ b/packages/drizzle-adapter/src/schema-check.test.ts @@ -2,6 +2,7 @@ import type { BetterAuthOptions } from "@better-auth/core"; import type { DBFieldAttribute } from "@better-auth/core/db"; import { getExpectedSchema, + runtimeSchemaCheckFor, SchemaMismatchError, schemaCheckFor, } from "@better-auth/core/db/internal"; @@ -9,6 +10,7 @@ import { relations } from "drizzle-orm"; import { pgTable, text } from "drizzle-orm/pg-core"; import { describe, expect, it } from "vitest"; import { drizzleAdapter } from "./drizzle-adapter"; +import { drizzleAdapter as relationsV2Adapter } from "./relations-v2"; import { findDrizzleSchemaProblems } from "./schema-check"; const secret = "test-secret-that-is-at-least-32-chars-long!!"; @@ -113,20 +115,37 @@ describe("drizzleAdapter", () => { it("registers a check the first request awaits", async () => { await expect( - schemaCheckFor(adapterFor(tablesFor({})))?.(), + runtimeSchemaCheckFor(adapterFor(tablesFor({})))?.(), ).resolves.toBeUndefined(); const { account: _account, ...partial } = tablesFor({}); - await expect(schemaCheckFor(adapterFor(partial))?.()).rejects.toThrow( - SchemaMismatchError, - ); + await expect( + runtimeSchemaCheckFor(adapterFor(partial))?.(), + ).rejects.toThrow(SchemaMismatchError); }); - it("registers nothing when the check is disabled", () => { + it("keeps explicit validation when runtime checks are disabled", async () => { const adapter = adapterFor( {}, { advanced: { database: { validateSchema: false } } }, ); - expect(schemaCheckFor(adapter)).toBeUndefined(); + expect(runtimeSchemaCheckFor(adapter)).toBeUndefined(); + await expect(schemaCheckFor(adapter)?.()).rejects.toThrow( + SchemaMismatchError, + ); + }); + + it("keeps explicit validation for Relations v2 when runtime checks are disabled", async () => { + const adapter = relationsV2Adapter( + { _: { fullSchema: {} } }, + { provider: "pg" }, + )({ + advanced: { database: { validateSchema: false } }, + secret, + }); + expect(runtimeSchemaCheckFor(adapter)).toBeUndefined(); + await expect(schemaCheckFor(adapter)?.()).rejects.toThrow( + SchemaMismatchError, + ); }); }); diff --git a/packages/kysely-adapter/src/kysely-adapter.ts b/packages/kysely-adapter/src/kysely-adapter.ts index 9cbca41af6..f39428af0b 100644 --- a/packages/kysely-adapter/src/kysely-adapter.ts +++ b/packages/kysely-adapter/src/kysely-adapter.ts @@ -1061,21 +1061,19 @@ export const kyselyAdapter = ( return (options: BetterAuthOptions): DBAdapter => { lazyOptions = options; const instance = adapter(options); - if (checksSchema(options)) { - registerSchemaCheck( - instance, - createSchemaCheck( - () => - findSchemaProblems( - db, - config?.type, - getExpectedSchema(options, { usePlural: config?.usePlural }), - ), - "database", - options.database, + const schemaCheck = createSchemaCheck( + () => + findSchemaProblems( + db, + config?.type, + getExpectedSchema(options, { usePlural: config?.usePlural }), ), - ); - } + "database", + options.database, + ); + registerSchemaCheck(instance, schemaCheck, { + runtimeEnabled: checksSchema(options), + }); return instance; }; }; diff --git a/packages/prisma-adapter/src/prisma-adapter.ts b/packages/prisma-adapter/src/prisma-adapter.ts index cbfd0cdde1..2bc06bdd98 100644 --- a/packages/prisma-adapter/src/prisma-adapter.ts +++ b/packages/prisma-adapter/src/prisma-adapter.ts @@ -853,15 +853,14 @@ export const prismaAdapter = (prisma: PrismaClient, config: PrismaConfig) => { lazyOptions = options; const instance = adapter(options); const dataModel = readPrismaDataModel(prisma); - if (dataModel && checksSchema(options)) { - registerSchemaCheck( - instance, - createSchemaCheck( - async () => - findPrismaSchemaProblems(dataModel, options, config.usePlural), - "prisma", - ), + if (dataModel) { + const schemaCheck = createSchemaCheck( + () => findPrismaSchemaProblems(dataModel, options, config.usePlural), + "prisma", ); + registerSchemaCheck(instance, schemaCheck, { + runtimeEnabled: checksSchema(options), + }); } return instance; }; diff --git a/packages/prisma-adapter/src/schema-check.test.ts b/packages/prisma-adapter/src/schema-check.test.ts index 862a56d7aa..a03ea08666 100644 --- a/packages/prisma-adapter/src/schema-check.test.ts +++ b/packages/prisma-adapter/src/schema-check.test.ts @@ -1,6 +1,7 @@ import type { BetterAuthOptions } from "@better-auth/core"; import { getExpectedSchema, + runtimeSchemaCheckFor, SchemaMismatchError, schemaCheckFor, } from "@better-auth/core/db/internal"; @@ -164,24 +165,25 @@ describe("prismaAdapter", () => { it("registers a check the first request awaits", async () => { await expect( - schemaCheckFor(adapterFor({ _runtimeDataModel: dataModelFor({}) }))?.(), + runtimeSchemaCheckFor( + adapterFor({ _runtimeDataModel: dataModelFor({}) }), + )?.(), ).resolves.toBeUndefined(); const { Account: _account, ...models } = dataModelFor({}).models; await expect( - schemaCheckFor(adapterFor({ _runtimeDataModel: { models } }))?.(), + runtimeSchemaCheckFor(adapterFor({ _runtimeDataModel: { models } }))?.(), ).rejects.toThrow(SchemaMismatchError); }); - it("registers nothing without a data model or when disabled", () => { + it("requires a data model and keeps explicit checks when runtime validation is disabled", async () => { + expect(runtimeSchemaCheckFor(adapterFor({}))).toBeUndefined(); expect(schemaCheckFor(adapterFor({}))).toBeUndefined(); - expect( - schemaCheckFor( - adapterFor( - { _runtimeDataModel: dataModelFor({}) }, - { advanced: { database: { validateSchema: false } } }, - ), - ), - ).toBeUndefined(); + const adapter = adapterFor( + { _runtimeDataModel: dataModelFor({}) }, + { advanced: { database: { validateSchema: false } } }, + ); + expect(runtimeSchemaCheckFor(adapter)).toBeUndefined(); + await expect(schemaCheckFor(adapter)?.()).resolves.toBeUndefined(); }); });