feat(adapters): register Drizzle and Prisma schema checks (#11179)

This commit is contained in:
Taesu
2026-09-05 23:03:15 +00:00
committed by GitHub
parent be0e007e20
commit 352d012bd5
13 changed files with 605 additions and 7 deletions
+10
View File
@@ -0,0 +1,10 @@
---
"@better-auth/core": patch
"@better-auth/drizzle-adapter": patch
"@better-auth/prisma-adapter": patch
"auth": patch
---
Validate Drizzle schema objects and generated Prisma client models during initialization, including in production, and report mismatches with guidance for fixing them. These checks do not query the database and cannot detect unapplied migrations.
For Prisma clients whose model metadata omits nullability, `auth generate` reports required fields that Better Auth never writes by reading the existing Prisma schema. Set `advanced.database.validateSchema: false` to disable runtime validation.
+1 -1
View File
@@ -127,7 +127,7 @@ await runMigrations();
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.
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. Custom adapters without a registered check are not validated.
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.
For adapters without schema validation, initialization logs a warning if you explicitly set `validateSchema: true`, or a debug message if you omit the option. Database operations proceed normally.
+65 -2
View File
@@ -1,15 +1,24 @@
import { existsSync } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import type { ResolvedDBTableIndex } from "@better-auth/core/db/internal";
import type {
ExpectedSchema,
IntrospectedTable,
ResolvedDBTableIndex,
} from "@better-auth/core/db/internal";
import {
diffSchema,
formatSchemaFinding,
getDatabaseIndexStringLength,
getPortableDatabaseIdentifierKey,
resolveDatabaseSchemaIndexes,
} from "@better-auth/core/db/internal";
import { BetterAuthError } from "@better-auth/core/error";
import { capitalizeFirstLetter } from "@better-auth/core/utils/string";
import { produceSchema } from "@mrleebo/prisma-ast";
import {
getSchema as parsePrismaSchema,
produceSchema,
} from "@mrleebo/prisma-ast";
import { initGetFieldName, initGetModelName } from "better-auth/adapters";
import type { DBFieldType } from "better-auth/db";
import { getAuthTables } from "better-auth/db";
@@ -101,6 +110,40 @@ function prismaIndexMatches(
);
}
/**
* The models an existing schema declares, read the way the comparison wants
* them: each model by name, each scalar or enum field by name. A relation
* field is not a column and is skipped.
*/
function introspectPrismaSchema(schemaPrisma: string): IntrospectedTable[] {
const models = parsePrismaSchema(schemaPrisma).list.filter(
(block) => block.type === "model",
);
const modelNames = new Set(models.map((model) => model.name));
return models.map((model) => ({
name: model.name,
columns: model.properties.flatMap((property) => {
if (property.type !== "field") return [];
const relation =
(typeof property.fieldType === "string" &&
modelNames.has(property.fieldType)) ||
property.attributes?.some((attribute) => attribute.name === "relation");
if (relation) return [];
return [
{
name: property.name,
nullable: property.optional === true,
hasDefault:
property.attributes?.some(
(attribute) =>
attribute.name === "default" || attribute.name === "updatedAt",
) ?? false,
},
];
}),
}));
}
export const generatePrismaSchema: SchemaGenerator = async ({
adapter,
options,
@@ -189,6 +232,17 @@ export const generatePrismaSchema: SchemaGenerator = async ({
});
}
const expectedModels: ExpectedSchema = {};
for (const table in tables) {
if (isMigrationDisabled(table)) continue;
const customModelName = tables[table]?.modelName || table;
const modelName = capitalizeFirstLetter(getModelName(customModelName));
const entry = (expectedModels[modelName] ??= { fields: {} });
for (const [field, attr] of Object.entries(tables[table]?.fields ?? {})) {
entry.fields[attr.fieldName || field] = attr;
}
}
const manyToManyRelations = new Map();
for (const table in tables) {
@@ -714,10 +768,19 @@ export const generatePrismaSchema: SchemaGenerator = async ({
const schemaChanged = schema.trim() !== schemaPrisma.trim();
// Columns the existing schema requires that Better Auth never writes. The
// generated code keeps them, so the user has to relax them by hand.
const schemaProblems = schemaPrismaExist
? diffSchema(expectedModels, introspectPrismaSchema(schemaPrisma))
.filter((finding) => finding.kind === "unexpected-required-column")
.map((finding) => formatSchemaFinding(finding, "prisma"))
: [];
return {
code: schemaChanged ? schema : "",
fileName: filePath,
overwrite: schemaPrismaExist && schemaChanged,
schemaProblems,
};
};
+55
View File
@@ -193,6 +193,61 @@ export const __schema = schema;
});
});
describe("prisma schema problems the generator cannot fix", () => {
const existing = (issuer: string) => `generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Account {
id String @id
accountId String
${issuer}
createdAt DateTime @default(now())
}
`;
const generate = async (schemaPrisma: string) => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ba-prisma-"));
const file = path.join(tmpDir, "schema.prisma");
fs.writeFileSync(file, schemaPrisma);
try {
return await generatePrismaSchema({
file,
adapter: prismaAdapter(
{},
{ provider: "postgresql" },
)({} as BetterAuthOptions),
options: { database: prismaAdapter({}, { provider: "postgresql" }) },
});
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
};
it("reports a required column Better Auth never writes", async () => {
const { schemaProblems } = await generate(existing("issuer String"));
expect(schemaProblems).toHaveLength(1);
expect(schemaProblems?.[0]).toContain(
'Column "issuer" on table "Account" is required but Better Auth never writes it',
);
expect(schemaProblems?.[0]).toContain("Remove it from the Prisma schema");
});
it("accepts an optional or defaulted extra column", async () => {
expect(
(await generate(existing("issuer String?"))).schemaProblems,
).toEqual([]);
expect(
(await generate(existing('issuer String @default("")')))
.schemaProblems,
).toEqual([]);
});
});
it("should generate prisma schema", async () => {
const schema = await generatePrismaSchema({
file: "test.prisma",
+9
View File
@@ -122,6 +122,15 @@ describe("SchemaMismatchError", () => {
);
expect(message).not.toContain(`${table}_issuer_accountId_uidx`);
});
it("words the fix for the source that holds the schema", () => {
expect(formatSchemaFinding(issuerDrift, "prisma")).toContain(
"Remove it from the Prisma schema",
);
expect(formatSchemaFinding(issuerDrift, "drizzle")).toContain(
"Remove it from the Drizzle schema",
);
});
});
/**
+9 -1
View File
@@ -132,6 +132,14 @@ const applyHint: Record<SchemaSource, string> = {
"Run `npx auth generate` to refresh the Prisma schema, then run `prisma migrate`.",
};
const relaxHint: Record<SchemaSource, string> = {
database: "Drop the column, make it nullable, or give it a database default.",
drizzle:
"Remove it from the Drizzle schema, make it nullable, or give it a default, then apply the change with your migration tool.",
prisma:
"Remove it from the Prisma schema, make it optional, or give it a default, then run `prisma migrate`.",
};
const sourceLabel: Record<SchemaSource, string> = {
database: "Database",
drizzle: "Drizzle",
@@ -155,7 +163,7 @@ export function formatSchemaFinding(
finding.column === "issuer"
? " If this column came from Better Auth 1.7.0 through 1.7.2, follow the upgrade guide before removing it: https://www.better-auth.com/docs/guides/1-7-upgrade-guide"
: "";
return `Column "${finding.column}" on table "${finding.table}" is required but Better Auth never writes it, so every insert into "${finding.table}" fails. Drop the column, make it nullable, or give it a database default.${issuer}`;
return `Column "${finding.column}" on table "${finding.table}" is required but Better Auth never writes it, so every insert into "${finding.table}" fails. ${relaxHint[source]}${issuer}`;
}
}
}
+2 -1
View File
@@ -475,7 +475,8 @@ export type BetterAuthAdvancedOptions = {
* Validate the schema during initialization and report problems
* through the configured logger. Authentication requests await
* the same check and fail when the schema does not match.
* Kysely introspects the database using its configured dialect.
* Kysely introspects the database; Drizzle and Prisma inspect
* local schema metadata without opening a connection.
* Set `false` to disable runtime schema validation.
*
* @default true
@@ -7,6 +7,11 @@ import type {
Where,
} from "@better-auth/core/db/adapter";
import { createAdapterFactory } from "@better-auth/core/db/adapter";
import {
checksSchema,
createSchemaCheck,
registerSchemaCheck,
} from "@better-auth/core/db/internal";
import { logger } from "@better-auth/core/env";
import { BetterAuthError } from "@better-auth/core/error";
import type { SQL } from "drizzle-orm";
@@ -42,6 +47,7 @@ import {
insensitiveNe,
insensitiveNotInArray,
} from "./query-builders";
import { findDrizzleSchemaProblems } from "./schema-check";
export interface DB {
[key: string]: any;
@@ -1206,6 +1212,21 @@ export const drizzleAdapter = (db: DB, config: DrizzleAdapterConfig) => {
const adapter = createAdapterFactory(adapterOptions);
return (options: BetterAuthOptions): DBAdapter<BetterAuthOptions> => {
lazyOptions = options;
return adapter(options);
const instance = adapter(options);
if (checksSchema(options)) {
registerSchemaCheck(
instance,
createSchemaCheck(
async () =>
findDrizzleSchemaProblems(
config.schema ?? db._?.fullSchema ?? {},
options,
config.usePlural,
),
"drizzle",
),
);
}
return instance;
};
};
@@ -0,0 +1,132 @@
import type { BetterAuthOptions } from "@better-auth/core";
import type { DBFieldAttribute } from "@better-auth/core/db";
import {
getExpectedSchema,
SchemaMismatchError,
schemaCheckFor,
} from "@better-auth/core/db/internal";
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 { findDrizzleSchemaProblems } from "./schema-check";
const secret = "test-secret-that-is-at-least-32-chars-long!!";
/**
* Column builders for the fields Better Auth writes, typed loosely on purpose.
*/
function columnsFor(fields: Record<string, DBFieldAttribute>) {
const columns: Record<string, ReturnType<typeof text>> = {
id: text("id").primaryKey(),
};
for (const [name, field] of Object.entries(fields)) {
columns[name] = field.required ? text(name).notNull() : text(name);
}
return columns;
}
/**
* Every table this configuration writes, declared the way `auth generate` would.
*/
function tablesFor(options: BetterAuthOptions, usePlural = false) {
const schema: Record<string, unknown> = {};
for (const [name, table] of Object.entries(
getExpectedSchema(options, { usePlural }),
)) {
schema[name] = pgTable(name, columnsFor(table.fields));
}
return schema;
}
describe("findDrizzleSchemaProblems", () => {
it("accepts a schema generated for this configuration", () => {
expect(findDrizzleSchemaProblems(tablesFor({}), {})).toEqual([]);
});
it("reports a table the schema does not export", () => {
const { account: _account, ...schema } = tablesFor({});
expect(findDrizzleSchemaProblems(schema, {})).toEqual([
{ kind: "missing-table", table: "account" },
]);
});
it("reports a column the adapter writes but the table lacks", () => {
const schema = tablesFor({});
const { accessToken: _accessToken, ...columns } = columnsFor(
getExpectedSchema({}).account!.fields,
);
schema.account = pgTable("account", columns);
expect(findDrizzleSchemaProblems(schema, {})).toEqual([
{ kind: "missing-column", table: "account", column: "accessToken" },
]);
});
it("reports a required column the adapter never fills and accepts one it can omit", () => {
const schema = tablesFor({});
schema.account = pgTable("account", {
...columnsFor(getExpectedSchema({}).account!.fields),
issuer: text("issuer").notNull(),
note: text("note"),
rank: text("rank").notNull().default("member"),
});
expect(findDrizzleSchemaProblems(schema, {})).toEqual([
{
kind: "unexpected-required-column",
table: "account",
column: "issuer",
},
]);
});
it("reads plural keys and skips relations", () => {
const schema = tablesFor({}, true);
const withRelations = {
...schema,
usersRelations: relations(pgTable("users", columnsFor({})), () => ({})),
};
expect(findDrizzleSchemaProblems(withRelations, {}, true)).toEqual([]);
expect(
findDrizzleSchemaProblems(withRelations, {}).map((f) => f.kind),
).toContain("missing-table");
});
it("follows renamed models and fields", () => {
const options: BetterAuthOptions = {
user: { modelName: "member", fields: { emailVerified: "verified" } },
};
const schema = tablesFor(options);
expect(schema.member).toBeDefined();
expect(findDrizzleSchemaProblems(schema, options)).toEqual([]);
});
});
describe("drizzleAdapter", () => {
const adapterFor = (schema: Record<string, unknown>, options = {}) =>
drizzleAdapter(
{ _: { fullSchema: schema } },
{ provider: "pg" },
)({
secret,
...options,
});
it("registers a check the first request awaits", async () => {
await expect(
schemaCheckFor(adapterFor(tablesFor({})))?.(),
).resolves.toBeUndefined();
const { account: _account, ...partial } = tablesFor({});
await expect(schemaCheckFor(adapterFor(partial))?.()).rejects.toThrow(
SchemaMismatchError,
);
});
it("registers nothing when the check is disabled", () => {
const adapter = adapterFor(
{},
{ advanced: { database: { validateSchema: false } } },
);
expect(schemaCheckFor(adapter)).toBeUndefined();
});
});
@@ -0,0 +1,47 @@
import type { BetterAuthOptions } from "@better-auth/core";
import type {
IntrospectedTable,
SchemaFinding,
} from "@better-auth/core/db/internal";
import { diffSchema, getExpectedSchema } from "@better-auth/core/db/internal";
import { getTableColumns, is, Table } from "drizzle-orm";
/**
* Reads a Drizzle schema object the way the adapter addresses it: each table
* by the key it is exported under, each column by its property name. Values
* that are not tables, such as relations, are skipped.
*/
function introspectDrizzleSchema(
schema: Record<string, unknown>,
): IntrospectedTable[] {
const tables: IntrospectedTable[] = [];
for (const [name, table] of Object.entries(schema)) {
if (!is(table, Table)) continue;
const columns = Object.entries(getTableColumns(table)).map(
([key, column]) => ({
name: key,
nullable: !column.notNull,
hasDefault:
column.hasDefault ||
column.generated !== undefined ||
column.generatedIdentity !== undefined,
}),
);
tables.push({ name, columns });
}
return tables;
}
/**
* Compares a Drizzle schema object with the tables this configuration writes.
*/
export function findDrizzleSchemaProblems(
schema: Record<string, unknown>,
options: BetterAuthOptions,
usePlural?: boolean | undefined,
): SchemaFinding[] {
return diffSchema(
getExpectedSchema(options, { usePlural }),
introspectDrizzleSchema(schema),
);
}
+19 -1
View File
@@ -8,7 +8,13 @@ import type {
Where,
} from "@better-auth/core/db/adapter";
import { createAdapterFactory } from "@better-auth/core/db/adapter";
import {
checksSchema,
createSchemaCheck,
registerSchemaCheck,
} from "@better-auth/core/db/internal";
import { BetterAuthError } from "@better-auth/core/error";
import { findPrismaSchemaProblems, readPrismaDataModel } from "./schema-check";
export interface PrismaConfig {
/**
@@ -824,6 +830,18 @@ export const prismaAdapter = (prisma: PrismaClient, config: PrismaConfig) => {
const adapter = createAdapterFactory(adapterOptions);
return (options: BetterAuthOptions): DBAdapter<BetterAuthOptions> => {
lazyOptions = options;
return adapter(options);
const instance = adapter(options);
const dataModel = readPrismaDataModel(prisma);
if (dataModel && checksSchema(options)) {
registerSchemaCheck(
instance,
createSchemaCheck(
async () =>
findPrismaSchemaProblems(dataModel, options, config.usePlural),
"prisma",
),
);
}
return instance;
};
};
@@ -0,0 +1,148 @@
import type { BetterAuthOptions } from "@better-auth/core";
import {
getExpectedSchema,
SchemaMismatchError,
schemaCheckFor,
} from "@better-auth/core/db/internal";
import { describe, expect, it, vi } from "vitest";
import { prismaAdapter } from "./prisma-adapter";
import type { PrismaRuntimeDataModel } from "./schema-check";
import { findPrismaSchemaProblems } from "./schema-check";
type Field = PrismaRuntimeDataModel["models"][string]["fields"][number];
const scalar = (name: string, isRequired = true): Field => ({
name,
kind: "scalar",
isRequired,
hasDefaultValue: false,
});
/**
* Every model this configuration writes, declared the way `auth generate` would.
*/
function dataModelFor(options: BetterAuthOptions): PrismaRuntimeDataModel {
const models: PrismaRuntimeDataModel["models"] = {};
for (const [name, table] of Object.entries(getExpectedSchema(options))) {
const model = name.charAt(0).toUpperCase() + name.slice(1);
models[model] = {
fields: [
scalar("id"),
...Object.entries(table.fields).map(([field, attribute]) =>
scalar(field, attribute.required !== false),
),
],
};
}
return { models };
}
/**
* The generated data model with extra fields on the account model.
*/
function withAccountFields(...extra: Field[]): PrismaRuntimeDataModel {
const { models } = dataModelFor({});
const account = models.Account!;
return {
models: { ...models, Account: { fields: [...account.fields, ...extra] } },
};
}
describe("findPrismaSchemaProblems", () => {
it("accepts a data model generated for this configuration", () => {
expect(findPrismaSchemaProblems(dataModelFor({}), {})).toEqual([]);
});
it("reports a model the client does not expose", () => {
const { Account: _account, ...models } = dataModelFor({}).models;
expect(findPrismaSchemaProblems({ models }, {})).toEqual([
{ kind: "missing-table", table: "account" },
]);
});
it("skips relation fields and fields Prisma fills itself", () => {
const dataModel = withAccountFields(
{
name: "user",
kind: "object",
isRequired: true,
hasDefaultValue: false,
},
{ ...scalar("touchedAt"), isUpdatedAt: true },
{ ...scalar("rank"), hasDefaultValue: true },
);
expect(findPrismaSchemaProblems(dataModel, {})).toEqual([]);
});
it("reports a required field the adapter never fills", () => {
const dataModel = withAccountFields(
scalar("issuer"),
scalar("note", false),
);
expect(findPrismaSchemaProblems(dataModel, {})).toEqual([
{
kind: "unexpected-required-column",
table: "account",
column: "issuer",
},
]);
});
});
describe("findPrismaSchemaProblems with the compact data model", () => {
/**
* The shape the `prisma-client` generator emits: names and kinds only.
*/
function compact(dataModel: PrismaRuntimeDataModel): PrismaRuntimeDataModel {
return {
models: Object.fromEntries(
Object.entries(dataModel.models).map(([model, { fields }]) => [
model,
{ fields: fields.map(({ name, kind }) => ({ name, kind })) },
]),
),
};
}
it("still reports a model or field the client does not expose", () => {
const { Account: _account, ...models } = compact(dataModelFor({})).models;
expect(findPrismaSchemaProblems({ models }, {})).toEqual([
{ kind: "missing-table", table: "account" },
]);
});
it("cannot tell a required field apart and stays silent about it", () => {
const dataModel = compact(withAccountFields(scalar("issuer")));
expect(findPrismaSchemaProblems(dataModel, {})).toEqual([]);
});
});
describe("prismaAdapter", () => {
const adapterFor = (client: object, options: BetterAuthOptions = {}) =>
prismaAdapter({ $transaction: vi.fn(), ...client } as never, {
provider: "postgresql",
})(options);
it("registers a check the first request awaits", async () => {
await expect(
schemaCheckFor(adapterFor({ _runtimeDataModel: dataModelFor({}) }))?.(),
).resolves.toBeUndefined();
const { Account: _account, ...models } = dataModelFor({}).models;
await expect(
schemaCheckFor(adapterFor({ _runtimeDataModel: { models } }))?.(),
).rejects.toThrow(SchemaMismatchError);
});
it("registers nothing without a data model or when disabled", () => {
expect(schemaCheckFor(adapterFor({}))).toBeUndefined();
expect(
schemaCheckFor(
adapterFor(
{ _runtimeDataModel: dataModelFor({}) },
{ advanced: { database: { validateSchema: false } } },
),
),
).toBeUndefined();
});
});
@@ -0,0 +1,86 @@
import type { BetterAuthOptions } from "@better-auth/core";
import type {
IntrospectedTable,
SchemaFinding,
} from "@better-auth/core/db/internal";
import { diffSchema, getExpectedSchema } from "@better-auth/core/db/internal";
/**
* The part of a generated client's data model the comparison reads. The
* `prisma-client` generator emits a compact model that carries names and
* kinds but no nullability or default, so those fields are optional.
*/
export interface PrismaRuntimeDataModel {
models: Record<
string,
{
fields: readonly {
name: string;
kind: "scalar" | "object" | "enum" | "unsupported";
isRequired?: boolean | undefined;
hasDefaultValue?: boolean | undefined;
isUpdatedAt?: boolean | undefined;
}[];
}
>;
}
/**
* The data model a generated client carries, or nothing for a client that
* predates it or stands in for one.
*/
export function readPrismaDataModel(
client: object,
): PrismaRuntimeDataModel | undefined {
const dataModel = (client as { _runtimeDataModel?: unknown })
._runtimeDataModel;
return typeof dataModel === "object" &&
dataModel !== null &&
"models" in dataModel
? (dataModel as PrismaRuntimeDataModel)
: undefined;
}
/**
* Prisma exposes the model `Account` as `prisma.account`.
*/
function clientProperty(model: string): string {
return model.charAt(0).toLowerCase() + model.slice(1);
}
/**
* Reads a generated client's data model the way the adapter addresses it:
* each model by its client property, each field by name. Relation fields are
* not columns and are skipped. A field whose model does not say whether it
* is required is taken as one an insert may omit, so a compact model reports
* missing tables and columns but never a required column.
*/
function introspectPrismaDataModel(
dataModel: PrismaRuntimeDataModel,
): IntrospectedTable[] {
return Object.entries(dataModel.models).map(([model, { fields }]) => ({
name: clientProperty(model),
columns: fields
.filter((field) => field.kind !== "object")
.map((field) => ({
name: field.name,
nullable: field.isRequired !== true,
hasDefault:
field.hasDefaultValue === true || field.isUpdatedAt === true,
})),
}));
}
/**
* Compares a generated client's data model with the tables this configuration writes.
*/
export function findPrismaSchemaProblems(
dataModel: PrismaRuntimeDataModel,
options: BetterAuthOptions,
usePlural?: boolean | undefined,
): SchemaFinding[] {
return diffSchema(
getExpectedSchema(options, { usePlural }),
introspectPrismaDataModel(dataModel),
);
}