diff --git a/.changeset/validate-database-schema.md b/.changeset/validate-database-schema.md index 9a6461cb77..8e55c43eb6 100644 --- a/.changeset/validate-database-schema.md +++ b/.changeset/validate-database-schema.md @@ -6,4 +6,4 @@ "@better-auth/prisma-adapter": minor --- -Validate the database schema against the Better Auth configuration and fail with the full list of problems instead of failing on the first insert. Missing tables and columns, and required columns Better Auth never writes, are reported with the change that fixes them. Drizzle and Prisma adapters check their schema definition when `betterAuth()` is called; the built-in database runs one introspection query before the first database call. `auth migrate` and `auth generate` report the same problems. Disable with `advanced.database.validateSchema: false`. +Validate the database schema against the Better Auth configuration and report every problem at once, with the change that fixes it, instead of failing on the first insert. Missing tables and columns, and required columns Better Auth never writes, throw for the core tables and warn for tables a plugin contributes. Drizzle and Prisma adapters check their schema definition when `betterAuth()` is called; the built-in database runs one introspection query before the first database call. `auth migrate` and `auth generate` report the same problems. Disable with `advanced.database.validateSchema: false`. diff --git a/docs/content/docs/concepts/database.mdx b/docs/content/docs/concepts/database.mdx index 43b98d0c2b..6aa1128b54 100644 --- a/docs/content/docs/concepts/database.mdx +++ b/docs/content/docs/concepts/database.mdx @@ -125,11 +125,13 @@ await runMigrations(); ### Schema Validation -Better Auth checks that the database can hold what it writes and fails with the full list of problems instead of failing on the first insert. +Better Auth checks that the database can hold what it writes and reports every problem at once, with the change that fixes it, instead of failing on the first insert. * Every table and column Better Auth writes must exist. * A column Better Auth does not write must be nullable or have a default. A required column without one rejects every insert into that table. +Problems on the core tables (`user`, `session`, `account`, `verification`) throw a `SchemaMismatchError`. Problems on tables or columns a plugin contributes are logged as a warning, so a plugin whose table is missing fails its own requests with a clear error while sign-in keeps working. + With the Drizzle and Prisma adapters the check runs against the schema definition when `betterAuth()` is called, so a stale schema file fails the build. With the built-in database it runs one introspection query before the first database call, so a build that never touches the database is unaffected and the first real request reports the problem. ```txt @@ -141,6 +143,8 @@ Set `advanced.database.validateSchema: false` to skip this check. `npx auth migrate` and `npx auth generate` report the same problems, so they surface during deployment as well. +The built-in check trusts what the database reports. Columns filled by a trigger, and generated columns on MySQL, look required with no default and are reported even though inserts succeed. On serverless platforms the introspection query runs once per cold start. + To skip the check, for example when a required column is filled by a database trigger, disable it: ```ts title="auth.ts" diff --git a/docs/content/docs/reference/options.mdx b/docs/content/docs/reference/options.mdx index 634d91cce0..8e4a9404cb 100644 --- a/docs/content/docs/reference/options.mdx +++ b/docs/content/docs/reference/options.mdx @@ -681,7 +681,7 @@ Set custom strategies for ID generation, findMany queries, and database joins. * `generateId`: Controls how record IDs are generated. Accepts a custom function, `false`, `"serial"`, or `"uuid"` (default: [random base62 string](https://github.com/better-auth/better-auth/blob/main/packages/core/src/utils/id.ts)). See the [Database documentation](/docs/concepts/database#id-generation) for more info. * `defaultFindManyLimit`: The default maximum number of records returned by the `findMany` adapter method. (default: `100`) * `joins`: Enable database joins for adapters that support them. When disabled (default), related data is fetched via separate queries. See the [Database documentation](/docs/concepts/database#joins) for more info. (default: `false`) -* `validateSchema`: Check the database schema against the configuration and fail with the list of required changes instead of failing on the first insert. Drizzle and Prisma check their schema definition when the adapter is created; the built-in database runs one introspection query before the first database call. See the [Database documentation](/docs/concepts/database#schema-validation) for more info. (default: `true`) +* `validateSchema`: Check the database schema against the configuration and report every required change at once instead of failing on the first insert. Core table problems throw, plugin table problems warn. Drizzle and Prisma check their schema definition when the adapter is created; the built-in database runs one introspection query before the first database call. See the [Database documentation](/docs/concepts/database#schema-validation) for more info. (default: `true`) ```ts import { betterAuth } from "better-auth"; diff --git a/packages/better-auth/src/db/adapter-kysely.ts b/packages/better-auth/src/db/adapter-kysely.ts index b7f70c47dd..8abd45084c 100644 --- a/packages/better-auth/src/db/adapter-kysely.ts +++ b/packages/better-auth/src/db/adapter-kysely.ts @@ -1,5 +1,6 @@ import type { BetterAuthOptions } from "@better-auth/core"; import type { DBAdapter } from "@better-auth/core/db/adapter"; +import { createLogger } from "@better-auth/core/env"; import { BetterAuthError } from "@better-auth/core/error"; import { getBaseAdapter } from "./adapter-base"; @@ -27,9 +28,21 @@ export async function getAdapter( "./validate-schema" ); const { getSchema } = await import("./get-schema"); - const expected = getSchema(opts); - return withSchemaValidation(adapter, () => - validateDatabaseSchema(kysely, databaseType || "sqlite", expected), + const expected = { + all: getSchema(opts), + core: getSchema({ ...opts, plugins: [] }), + }; + const logger = createLogger(opts.logger); + return withSchemaValidation( + adapter, + () => + validateDatabaseSchema( + kysely, + databaseType || "sqlite", + expected, + (m) => logger.warn(m), + ), + logger, ); }); } diff --git a/packages/better-auth/src/db/get-migration.test.ts b/packages/better-auth/src/db/get-migration.test.ts index a0cff338cf..34e2fec4f2 100644 --- a/packages/better-auth/src/db/get-migration.test.ts +++ b/packages/better-auth/src/db/get-migration.test.ts @@ -787,7 +787,8 @@ describe("get-migration: schema problems the migration cannot fix", () => { }); expect(plan.schemaProblems).toHaveLength(1); - expect(plan.schemaProblems[0]).toContain( + expect(plan.schemaProblems[0]?.core).toBe(true); + expect(plan.schemaProblems[0]?.message).toContain( 'Column "tier" on table "user" is required but Better Auth never writes it', ); expect(plan.toBeAdded).toEqual([]); diff --git a/packages/better-auth/src/db/get-migration.ts b/packages/better-auth/src/db/get-migration.ts index 5e49256ed2..d39ac9dbd2 100644 --- a/packages/better-auth/src/db/get-migration.ts +++ b/packages/better-auth/src/db/get-migration.ts @@ -12,6 +12,7 @@ import { getDatabaseFieldIndexName, getDatabaseIndexStringLength, getPortableDatabaseIdentifierKey, + splitSchemaFindings, } from "@better-auth/core/db/internal"; import { createLogger } from "@better-auth/core/env"; import { BetterAuthError } from "@better-auth/core/error"; @@ -740,13 +741,20 @@ export async function getMigrations( ); } // Columns the migration cannot fix: required, no default, and never written - // by Better Auth. Reported so the CLI can stop before an insert would fail. - const schemaProblems = diffSchema( + // by Better Auth. `core` marks the ones on tables Better Auth itself writes, + // which the CLI refuses to migrate past; plugin ones only warn. + const strayColumns = diffSchema( betterAuthSchema, toIntrospectedTables(tableMetadata), - ) - .filter((finding) => finding.kind === "unexpected-required-column") - .map((finding) => formatSchemaFinding(finding, "database")); + ).filter((finding) => finding.kind === "unexpected-required-column"); + const coreStrayColumns = new Set( + splitSchemaFindings(strayColumns, getSchema({ ...config, plugins: [] })) + .core, + ); + const schemaProblems = strayColumns.map((finding) => ({ + message: formatSchemaFinding(finding, "database"), + core: coreStrayColumns.has(finding), + })); const toBeCreated: { table: string; diff --git a/packages/better-auth/src/db/validate-schema.test.ts b/packages/better-auth/src/db/validate-schema.test.ts index e7c7d4d76f..793384747f 100644 --- a/packages/better-auth/src/db/validate-schema.test.ts +++ b/packages/better-auth/src/db/validate-schema.test.ts @@ -1,9 +1,12 @@ import { DatabaseSync } from "node:sqlite"; import { SchemaMismatchError } from "@better-auth/core/db/internal"; -import { describe, expect, it } from "vitest"; +import type { TableMetadata } from "kysely"; +import { describe, expect, it, vi } from "vitest"; import { betterAuth } from "../auth/full"; +import { twoFactor } from "../plugins/two-factor"; import type { BetterAuthOptions } from "../types"; import { getMigrations } from "./get-migration"; +import { selectVisibleTables, withSchemaValidation } from "./validate-schema"; const legacyAccountTable = ` DROP TABLE account; @@ -92,6 +95,29 @@ describe("database schema validation", () => { ).rejects.toBeInstanceOf(SchemaMismatchError); }); + it("warns instead of failing when only a plugin table is wrong", async () => { + const database = new DatabaseSync(":memory:"); + const log = vi.fn(); + const options = { + ...baseOptions, + database, + logger: { log }, + plugins: [twoFactor()], + } satisfies BetterAuthOptions; + await createMigratedDatabase(options); + database.exec("DROP TABLE twoFactor"); + const auth = betterAuth(options); + + const result = await auth.api.signUpEmail({ + body: { email: "p@example.com", password: "password123", name: "P" }, + }); + expect(result.user.email).toBe("p@example.com"); + expect(log).toHaveBeenCalledWith( + "warn", + expect.stringContaining('Table "twoFactor" is missing'), + ); + }); + it("can be disabled", async () => { const database = new DatabaseSync(":memory:"); const options = { @@ -113,3 +139,75 @@ describe("database schema validation", () => { expect(String((failure as Error).message)).toMatch(/issuer/); }); }); + +describe("withSchemaValidation", () => { + const makeAdapter = () => { + const findOne = vi.fn(async () => "row"); + return { + adapter: { findOne } as unknown as Parameters< + typeof withSchemaValidation + >[0], + findOne, + }; + }; + + it("keeps a schema mismatch without querying the database again", async () => { + const { adapter } = makeAdapter(); + const validate = vi.fn(async () => { + throw new SchemaMismatchError("mismatch", []); + }); + const guarded = withSchemaValidation(adapter, validate, { warn: vi.fn() }); + + await expect(guarded.findOne({ model: "user", where: [] })).rejects.toBe( + await validate.mock.results[0]?.value.catch((error: unknown) => error), + ); + await expect( + guarded.findOne({ model: "user", where: [] }), + ).rejects.toBeInstanceOf(SchemaMismatchError); + expect(validate).toHaveBeenCalledTimes(1); + }); + + it("continues with a warning when the schema cannot be read", async () => { + const { adapter, findOne } = makeAdapter(); + const validate = vi.fn(async () => { + throw new Error("introspection unsupported"); + }); + const warn = vi.fn(); + const guarded = withSchemaValidation(adapter, validate, { warn }); + + await expect(guarded.findOne({ model: "user", where: [] })).resolves.toBe( + "row", + ); + await guarded.findOne({ model: "user", where: [] }); + expect(findOne).toHaveBeenCalledTimes(2); + expect(validate).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledTimes(1); + }); +}); + +describe("selectVisibleTables", () => { + const table = (schema: string, name: string): TableMetadata => ({ + schema, + name, + isView: false, + columns: [], + }); + + it("prefers the current schema and falls back to other schemas on the search path", () => { + const selected = selectVisibleTables( + [ + table("public", "user"), + table("auth", "user"), + table("auth", "session"), + table("archive", "session"), + table("archive", "account"), + ], + "public", + ); + expect(selected.map((t) => `${t.schema}.${t.name}`)).toEqual([ + "public.user", + "auth.session", + "archive.account", + ]); + }); +}); diff --git a/packages/better-auth/src/db/validate-schema.ts b/packages/better-auth/src/db/validate-schema.ts index 27335d3bdb..9c8e6a3aed 100644 --- a/packages/better-auth/src/db/validate-schema.ts +++ b/packages/better-auth/src/db/validate-schema.ts @@ -6,14 +6,35 @@ import type { } from "@better-auth/core/db/internal"; import { diffSchema, - formatSchemaFindings, + reportSchemaFindings, SchemaMismatchError, } from "@better-auth/core/db/internal"; +import type { InternalLogger } from "@better-auth/core/env"; import type { KyselyDatabaseType } from "@better-auth/kysely-adapter"; -import type { Kysely } from "kysely"; +import type { Kysely, TableMetadata } from "kysely"; import { getMssqlSchema, getPostgresSchema } from "./get-migration"; import { toIntrospectedTables } from "./introspect"; +/** + * Picks the tables an unqualified statement resolves to. Tables in the + * current schema win. A name the current schema lacks falls back to any other + * schema that has it, because a Postgres `search_path` can list several + * schemas and the connection reaches all of them. + */ +export function selectVisibleTables( + tables: readonly TableMetadata[], + currentSchema: string, +): TableMetadata[] { + const visible = tables.filter((table) => table.schema === currentSchema); + const seen = new Set(visible.map((table) => table.name)); + for (const table of tables) { + if (table.schema === currentSchema || seen.has(table.name)) continue; + seen.add(table.name); + visible.push(table); + } + return visible; +} + /** Reads the tables visible to unqualified statements on this connection. */ export async function introspectDatabaseTables( db: Kysely, @@ -25,35 +46,35 @@ export async function introspectDatabaseTables( dbType === "postgres" ? await getPostgresSchema(db) : await getMssqlSchema(db); - return toIntrospectedTables( - tables.filter((table) => table.schema === schema), - ); + return toIntrospectedTables(selectVisibleTables(tables, schema)); } return toIntrospectedTables(tables); } /** * Compares the live database with the tables this configuration writes. - * `expected` is resolved when the adapter is created so later mutation of + * Both schemas are resolved when the adapter is created so later mutation of * plugin schema objects cannot change what is checked. * - * @throws {SchemaMismatchError} listing every missing table or column and - * every required column Better Auth never fills. + * @throws {SchemaMismatchError} when a core table or column is wrong. Plugin + * findings are only warned about. */ export async function validateDatabaseSchema( db: Kysely, dbType: KyselyDatabaseType, - expected: ExpectedSchema, + expected: { all: ExpectedSchema; core: ExpectedSchema }, + warn: (message: string) => void, ): Promise { const findings = diffSchema( - expected, + expected.all, await introspectDatabaseTables(db, dbType), ); - if (findings.length === 0) return; - throw new SchemaMismatchError( - formatSchemaFindings(findings, "database"), + reportSchemaFindings({ findings, - ); + core: expected.core, + source: "database", + warn, + }); } const guardedMethods = [ @@ -69,20 +90,30 @@ const guardedMethods = [ ] as const; /** - * Runs `validate` once before the first database call and fails that call - * with the validation error. `betterAuth()` initializes eagerly, often while a - * build step evaluates the module, so the database is only contacted once an - * actual request needs it. A failed validation is retried on the next call so - * a transient connection error does not pin the process into a broken state. + * Runs `validate` once before the first database call. `betterAuth()` + * initializes eagerly, often while a build step evaluates the module, so the + * database is only contacted once an actual request needs it. + * + * A {@link SchemaMismatchError} is kept and rethrown on every later call + * without querying the database again. Any other failure means the schema + * could not be read, for a dialect without introspection or a role without + * catalog access, so it is logged once and the call proceeds. */ export function withSchemaValidation< Adapter extends DBAdapter, ->(adapter: Adapter, validate: () => Promise): Adapter { +>( + adapter: Adapter, + validate: () => Promise, + logger: Pick, +): Adapter { let pending: Promise | undefined; const ensureValidated = () => { pending ??= validate().catch((error: unknown) => { - pending = undefined; - throw error; + if (error instanceof SchemaMismatchError) throw error; + logger.warn( + "Could not verify the database schema, continuing without the check.", + error, + ); }); return pending; }; diff --git a/packages/cli/src/commands/generate.ts b/packages/cli/src/commands/generate.ts index 82b91ae334..90fccb9c19 100644 --- a/packages/cli/src/commands/generate.ts +++ b/packages/cli/src/commands/generate.ts @@ -222,7 +222,9 @@ async function generateAction(opts: any) { ), ); for (const problem of schema.schemaProblems) { - console.error(chalk.red(`-> ${problem}`)); + console.error( + (problem.core ? chalk.red : chalk.yellow)(`-> ${problem.message}`), + ); } } if (!schema.code) { diff --git a/packages/cli/src/commands/migrate.ts b/packages/cli/src/commands/migrate.ts index bbbb49a804..b45835a984 100644 --- a/packages/cli/src/commands/migrate.ts +++ b/packages/cli/src/commands/migrate.ts @@ -125,15 +125,24 @@ export async function migrateAction(opts: any) { } catch {} process.exit(1); } - if (plan.schemaProblems.length) { + const coreProblems = plan.schemaProblems.filter((problem) => problem.core); + const pluginProblems = plan.schemaProblems.filter((problem) => !problem.core); + if (pluginProblems.length) { + spinner.stop(); + for (const problem of pluginProblems) { + console.warn(chalk.yellow(`⚠ ${problem.message}`)); + } + spinner.start(); + } + if (coreProblems.length) { spinner.stop(); console.error( chalk.red.bold( - `The database has ${plan.schemaProblems.length} ${plan.schemaProblems.length === 1 ? "column" : "columns"} Better Auth cannot write to. Nothing ran.`, + `The database has ${coreProblems.length} ${coreProblems.length === 1 ? "column" : "columns"} Better Auth cannot write to. Nothing ran.`, ), ); - for (const problem of plan.schemaProblems) { - console.error(chalk.red(`-> ${problem}`)); + for (const problem of coreProblems) { + console.error(chalk.red(`-> ${problem.message}`)); } process.exit(1); } diff --git a/packages/cli/src/generators/types.ts b/packages/cli/src/generators/types.ts index 96b22e23c4..79f4eee192 100644 --- a/packages/cli/src/generators/types.ts +++ b/packages/cli/src/generators/types.ts @@ -15,7 +15,7 @@ export interface SchemaGeneratorResult { * Columns the database already has that Better Auth never writes and that * reject inserts. No generated statement fixes them. */ - schemaProblems?: string[]; + schemaProblems?: { message: string; core: boolean }[]; } export interface SchemaGenerator { diff --git a/packages/core/src/db/internal.ts b/packages/core/src/db/internal.ts index f11e79e86c..eadd24c822 100644 --- a/packages/core/src/db/internal.ts +++ b/packages/core/src/db/internal.ts @@ -17,7 +17,9 @@ export { formatSchemaFindings, type IntrospectedColumn, type IntrospectedTable, + reportSchemaFindings, type SchemaFinding, SchemaMismatchError, type SchemaSource, + splitSchemaFindings, } from "./schema-diff"; diff --git a/packages/core/src/db/schema-diff.test.ts b/packages/core/src/db/schema-diff.test.ts index be8bf5460c..e71f471c92 100644 --- a/packages/core/src/db/schema-diff.test.ts +++ b/packages/core/src/db/schema-diff.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; import type { IntrospectedTable } from "./schema-diff"; -import { diffSchema, formatSchemaFindings } from "./schema-diff"; +import { + diffSchema, + formatSchemaFindings, + splitSchemaFindings, +} from "./schema-diff"; const expected = { account: { @@ -89,3 +93,32 @@ describe("formatSchemaFindings", () => { ); }); }); + +describe("splitSchemaFindings", () => { + it("keeps core tables and their own columns as core, everything else as plugin", () => { + const core = { + user: { fields: { email: { type: "string" } } }, + account: { fields: {} }, + } as const; + const split = splitSchemaFindings( + [ + { kind: "missing-column", table: "user", column: "email" }, + { kind: "missing-column", table: "user", column: "twoFactorEnabled" }, + { + kind: "unexpected-required-column", + table: "account", + column: "issuer", + }, + { kind: "missing-table", table: "twoFactor" }, + { kind: "unexpected-required-column", table: "passkey", column: "x" }, + ], + core, + ); + expect(split.core.map((f) => f.table)).toEqual(["user", "account"]); + expect(split.plugin.map((f) => f.table)).toEqual([ + "user", + "twoFactor", + "passkey", + ]); + }); +}); diff --git a/packages/core/src/db/schema-diff.ts b/packages/core/src/db/schema-diff.ts index 743c2812de..3b219dc4d6 100644 --- a/packages/core/src/db/schema-diff.ts +++ b/packages/core/src/db/schema-diff.ts @@ -124,3 +124,53 @@ export function formatSchemaFindings( "Set `advanced.database.validateSchema: false` to skip this check.", ].join("\n"); } + +/** + * Separates findings on what Better Auth itself writes from findings on what + * plugins contribute. `core` is the expected schema computed without plugins. + * A required column Better Auth never writes on a core table counts as core, + * because it blocks core inserts. + */ +export function splitSchemaFindings( + findings: readonly SchemaFinding[], + core: ExpectedSchema, +): { core: SchemaFinding[]; plugin: SchemaFinding[] } { + const split = { core: [] as SchemaFinding[], plugin: [] as SchemaFinding[] }; + for (const finding of findings) { + const coreTable = core[finding.table]; + const isCore = + coreTable !== undefined && + (finding.kind !== "missing-column" || + finding.column === "id" || + finding.column in coreTable.fields); + split[isCore ? "core" : "plugin"].push(finding); + } + return split; +} + +/** + * Applies the shared policy: throw when a core table or column is wrong, + * otherwise warn so a plugin whose table is missing only fails its own + * requests with a clear error. + * + * @throws {SchemaMismatchError} when any finding concerns core tables. + */ +export function reportSchemaFindings({ + findings, + core, + source, + warn, +}: { + findings: readonly SchemaFinding[]; + core: ExpectedSchema; + source: SchemaSource; + warn: (message: string) => void; +}): void { + if (findings.length === 0) return; + const split = splitSchemaFindings(findings, core); + const message = formatSchemaFindings(findings, source); + if (split.core.length > 0) { + throw new SchemaMismatchError(message, findings); + } + warn(message); +} diff --git a/packages/drizzle-adapter/src/drizzle-adapter.ts b/packages/drizzle-adapter/src/drizzle-adapter.ts index ddd922248a..cdb8a439e4 100644 --- a/packages/drizzle-adapter/src/drizzle-adapter.ts +++ b/packages/drizzle-adapter/src/drizzle-adapter.ts @@ -1,4 +1,5 @@ import type { BetterAuthOptions } from "@better-auth/core"; +import type { BetterAuthDBSchema } from "@better-auth/core/db"; import { getAuthTables } from "@better-auth/core/db"; import type { AdapterFactoryCustomizeAdapterCreator, @@ -18,10 +19,9 @@ import type { } from "@better-auth/core/db/internal"; import { diffSchema, - formatSchemaFindings, - SchemaMismatchError, + reportSchemaFindings, } from "@better-auth/core/db/internal"; -import { logger } from "@better-auth/core/env"; +import { createLogger, logger } from "@better-auth/core/env"; import { BetterAuthError } from "@better-auth/core/error"; import type { SQL } from "drizzle-orm"; import { @@ -511,7 +511,7 @@ function createDrizzleMigrationConnection( * the `usePlural` suffix) and `table[fieldName]`. Runs once when the adapter * is constructed, so a stale schema fails the build instead of the first insert. * - * @throws {SchemaMismatchError} listing every missing table or column and + * @throws {SchemaMismatchError} when a core table or column is wrong. Plugin * every required column Better Auth never fills. */ function validateDrizzleSchema( @@ -519,14 +519,18 @@ function validateDrizzleSchema( options: BetterAuthOptions, usePlural: boolean, ) { - const expected: ExpectedSchema = {}; - for (const table of Object.values(getAuthTables(options))) { - const key = usePlural ? `${table.modelName}s` : table.modelName; - const fields = (expected[key] ??= { fields: {} }).fields; - for (const [fieldKey, field] of Object.entries(table.fields)) { - fields[field.fieldName || fieldKey] = field; + const toExpected = (tables: BetterAuthDBSchema) => { + const expected: ExpectedSchema = {}; + for (const table of Object.values(tables)) { + const key = usePlural ? `${table.modelName}s` : table.modelName; + const fields = (expected[key] ??= { fields: {} }).fields; + for (const [fieldKey, field] of Object.entries(table.fields)) { + fields[field.fieldName || fieldKey] = field; + } } - } + return expected; + }; + const expected = toExpected(getAuthTables(options)); const actual: IntrospectedTable[] = []; for (const [name, table] of Object.entries(drizzleSchema)) { if (!isTable(table)) continue; @@ -543,12 +547,12 @@ function validateDrizzleSchema( })), }); } - const findings = diffSchema(expected, actual); - if (findings.length === 0) return; - throw new SchemaMismatchError( - formatSchemaFindings(findings, "drizzle"), - findings, - ); + reportSchemaFindings({ + findings: diffSchema(expected, actual), + core: toExpected(getAuthTables({ ...options, plugins: [] })), + source: "drizzle", + warn: (message) => createLogger(options.logger).warn(message), + }); } export interface DrizzleAdapterConfig { diff --git a/packages/drizzle-adapter/src/drizzle-schema-validation.test.ts b/packages/drizzle-adapter/src/drizzle-schema-validation.test.ts index 57ff03a0f7..c31b18b904 100644 --- a/packages/drizzle-adapter/src/drizzle-schema-validation.test.ts +++ b/packages/drizzle-adapter/src/drizzle-schema-validation.test.ts @@ -1,7 +1,7 @@ import { SchemaMismatchError } from "@better-auth/core/db/internal"; import type { PgTable } from "drizzle-orm/pg-core"; import { boolean, pgTable, text, timestamp } from "drizzle-orm/pg-core"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { drizzleAdapter } from "./drizzle-adapter"; const user = pgTable("user", { @@ -100,6 +100,31 @@ describe("drizzle schema validation", () => { expect(build(account)).not.toThrow(); }); + it("warns instead of failing when only a plugin table is wrong", () => { + const account = pgTable("account", { + ...accountColumns, + scope: text("scope"), + }); + const log = vi.fn(); + expect( + build(account, { + logger: { log }, + plugins: [ + { + id: "widgets", + schema: { + widget: { fields: { name: { type: "string", required: true } } }, + }, + }, + ], + }), + ).not.toThrow(); + expect(log).toHaveBeenCalledWith( + "warn", + expect.stringContaining('Table "widget" is missing'), + ); + }); + it("skips the check when validateSchema is false", () => { const account = pgTable("account", accountColumns); expect( diff --git a/packages/prisma-adapter/src/prisma-adapter.ts b/packages/prisma-adapter/src/prisma-adapter.ts index 76c02e0fba..db7a5ac3e7 100644 --- a/packages/prisma-adapter/src/prisma-adapter.ts +++ b/packages/prisma-adapter/src/prisma-adapter.ts @@ -19,9 +19,9 @@ import type { } from "@better-auth/core/db/internal"; import { diffSchema, - formatSchemaFindings, - SchemaMismatchError, + reportSchemaFindings, } from "@better-auth/core/db/internal"; +import { createLogger } from "@better-auth/core/env"; import { BetterAuthError } from "@better-auth/core/error"; /** Controls the Prisma interactive transaction used by release migrations. */ @@ -166,22 +166,30 @@ function findPrismaModel( */ function validatePrismaSchema( runtimeDataModel: PrismaRuntimeDataModel, - schema: BetterAuthDBSchema, + options: BetterAuthOptions, ): void { - const expected: ExpectedSchema = {}; + const toExpected = (tables: BetterAuthDBSchema) => { + const expected: ExpectedSchema = {}; + for (const table of Object.values(tables)) { + const modelEntry = findPrismaModel(runtimeDataModel, table.modelName); + expected[modelEntry?.[0] ?? table.modelName] = { + fields: Object.fromEntries( + Object.entries(table.fields).map(([key, field]) => [ + field.fieldName || key, + field, + ]), + ), + }; + } + return expected; + }; + const schema = getAuthTables(options); + const expected = toExpected(schema); const actual: IntrospectedTable[] = []; for (const table of Object.values(schema)) { const modelEntry = findPrismaModel(runtimeDataModel, table.modelName); - const name = modelEntry?.[0] ?? table.modelName; - expected[name] = { - fields: Object.fromEntries( - Object.entries(table.fields).map(([key, field]) => [ - field.fieldName || key, - field, - ]), - ), - }; if (!modelEntry) continue; + const name = modelEntry[0]; actual.push({ name, columns: modelEntry[1].fields @@ -197,12 +205,12 @@ function validatePrismaSchema( })), }); } - const findings = diffSchema(expected, actual); - if (findings.length === 0) return; - throw new SchemaMismatchError( - formatSchemaFindings(findings, "prisma"), - findings, - ); + reportSchemaFindings({ + findings: diffSchema(expected, actual), + core: toExpected(getAuthTables({ ...options, plugins: [] })), + source: "prisma", + warn: (message) => createLogger(options.logger).warn(message), + }); } const defaultMigrationTransaction = { @@ -1094,7 +1102,7 @@ export const prismaAdapter = (prisma: PrismaClient, config: PrismaConfig) => { runtimeDataModel && options.advanced?.database?.validateSchema !== false ) { - validatePrismaSchema(runtimeDataModel, getAuthTables(options)); + validatePrismaSchema(runtimeDataModel, options); } return created; }; diff --git a/packages/prisma-adapter/src/prisma-schema-validation.test.ts b/packages/prisma-adapter/src/prisma-schema-validation.test.ts index 0c36f3afa3..6228a0bd9e 100644 --- a/packages/prisma-adapter/src/prisma-schema-validation.test.ts +++ b/packages/prisma-adapter/src/prisma-schema-validation.test.ts @@ -114,6 +114,32 @@ describe("prisma schema validation", () => { expect(() => create({ models: models() })).not.toThrow(); }); + it("warns instead of failing when only a plugin model is missing", () => { + const log = vi.fn(); + expect(() => + create( + { models: models() }, + { + logger: { log }, + plugins: [ + { + id: "widgets", + schema: { + widget: { + fields: { name: { type: "string", required: true } }, + }, + }, + }, + ], + }, + ), + ).not.toThrow(); + expect(log).toHaveBeenCalledWith( + "warn", + expect.stringContaining('Table "widget" is missing'), + ); + }); + it("reports a field missing from a Prisma model", () => { const m = models(); m.Account.fields = m.Account.fields.filter((f) => f.name !== "scope");