feat(cli): add check command for schema validation (#11314)

This commit is contained in:
Taesu
2026-09-24 14:41:49 +00:00
committed by GitHub
parent 631ac296a5
commit dcaa5a7869
23 changed files with 627 additions and 102 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"auth": patch
---
Add read-only `check` and `check schema` commands to validate the configured adapter schema on demand.
+27 -2
View File
@@ -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.
</Callout>
## 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.
+1 -1
View File
@@ -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.
@@ -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",
});
});
});
@@ -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<Options>;
}
@@ -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);
});
});
+180
View File
@@ -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);
});
});
+120
View File
@@ -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<void> {
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<ReturnType<typeof getAuth>>;
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<void> {
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 <cwd>",
"the working directory. defaults to the current directory.",
process.cwd(),
)
.option(
"--config <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()));
+2
View File
@@ -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)
+32 -12
View File
@@ -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<AuthContext>;
};
async function getLoadedConfig({
cwd,
configPath,
outputPath,
shouldThrowOnError = false,
}: ConfigInput): Promise<LoadedAuth | null> {
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<LoadedAuth>({
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 };
}
+74
View File
@@ -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"),
});
});
});
+14
View File
@@ -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,
},
},
});
+2 -2
View File
@@ -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<void>> = [];
let result: Awaited<R>;
+1
View File
@@ -15,6 +15,7 @@ export {
createSchemaCheck,
invalidateSchemaChecks,
registerSchemaCheck,
runtimeSchemaCheckFor,
type SchemaCheck,
schemaCheckFor,
} from "./schema-check";
+12
View File
@@ -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();
});
});
+37 -9
View File
@@ -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<void> | undefined;
export type SchemaCheck = (() => Promise<void> | undefined) & {
source?: SchemaSource;
};
const schemaChecks = new WeakMap<object, SchemaCheck>();
const schemaChecks = new WeakMap<
object,
{ check: SchemaCheck; runtimeEnabled: boolean }
>();
const schemaRevisions = new WeakMap<object, { value: number }>();
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<SchemaFinding[]>,
find: () => Awaitable<SchemaFinding[]>,
source: SchemaSource,
database?: object,
): SchemaCheck {
@@ -81,7 +105,9 @@ export function createSchemaCheck(
let checkedRevision = revision?.value;
let clean = false;
let verdict: Promise<void> | undefined;
return function checkSchema(): Promise<void> | undefined {
const checkSchema: SchemaCheck = function checkSchema():
| Promise<void>
| undefined {
const currentRevision = revision?.value;
if (checkedRevision !== currentRevision) {
checkedRevision = currentRevision;
@@ -104,4 +130,6 @@ export function createSchemaCheck(
},
));
};
checkSchema.source = source;
return checkSchema;
}
+4
View File
@@ -428,6 +428,10 @@ export type AuthContext<Options extends BetterAuthOptions = BetterAuthOptions> =
* 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<Options>;
createAuthCookie: CreateCookieGetterFn;
secret: string;
+11 -13
View File
@@ -1259,20 +1259,18 @@ export const drizzleAdapter = (db: DB, config: DrizzleAdapterConfig) => {
return (options: BetterAuthOptions): DBAdapter<BetterAuthOptions> => {
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;
};
};
@@ -1193,26 +1193,24 @@ export const drizzleAdapter = (db: DB, config: DrizzleAdapterConfig) => {
return (options: BetterAuthOptions): DBAdapter<BetterAuthOptions> => {
lazyOptions = options;
const instance = adapter(options);
if (checksSchema(options)) {
registerSchemaCheck(
instance,
createSchemaCheck(async () => {
const relations: Record<string, { table: unknown }> =
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<string, { table: unknown }> =
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;
};
};
@@ -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,
);
});
});
+12 -14
View File
@@ -1061,21 +1061,19 @@ export const kyselyAdapter = (
return (options: BetterAuthOptions): DBAdapter<BetterAuthOptions> => {
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;
};
};
@@ -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;
};
@@ -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();
});
});