diff --git a/.changeset/calm-models-register-checks.md b/.changeset/calm-models-register-checks.md new file mode 100644 index 0000000000..3879a422bf --- /dev/null +++ b/.changeset/calm-models-register-checks.md @@ -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. diff --git a/docs/content/docs/concepts/database.mdx b/docs/content/docs/concepts/database.mdx index 3b2dca8f23..749d798bf3 100644 --- a/docs/content/docs/concepts/database.mdx +++ b/docs/content/docs/concepts/database.mdx @@ -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. diff --git a/packages/cli/src/generators/prisma.ts b/packages/cli/src/generators/prisma.ts index 341c72a92a..25e4974405 100644 --- a/packages/cli/src/generators/prisma.ts +++ b/packages/cli/src/generators/prisma.ts @@ -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, }; }; diff --git a/packages/cli/test/generate.test.ts b/packages/cli/test/generate.test.ts index 261b215096..21abbc0e62 100644 --- a/packages/cli/test/generate.test.ts +++ b/packages/cli/test/generate.test.ts @@ -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", diff --git a/packages/core/src/db/schema-diff.test.ts b/packages/core/src/db/schema-diff.test.ts index 12f5c3baba..f22e4a6316 100644 --- a/packages/core/src/db/schema-diff.test.ts +++ b/packages/core/src/db/schema-diff.test.ts @@ -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", + ); + }); }); /** diff --git a/packages/core/src/db/schema-diff.ts b/packages/core/src/db/schema-diff.ts index a6a6704b07..ab3dfc4e31 100644 --- a/packages/core/src/db/schema-diff.ts +++ b/packages/core/src/db/schema-diff.ts @@ -132,6 +132,14 @@ const applyHint: Record = { "Run `npx auth generate` to refresh the Prisma schema, then run `prisma migrate`.", }; +const relaxHint: Record = { + 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 = { 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}`; } } } diff --git a/packages/core/src/types/init-options.ts b/packages/core/src/types/init-options.ts index d47b38e64d..71fe569f28 100644 --- a/packages/core/src/types/init-options.ts +++ b/packages/core/src/types/init-options.ts @@ -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 diff --git a/packages/drizzle-adapter/src/drizzle-adapter.ts b/packages/drizzle-adapter/src/drizzle-adapter.ts index 924b1f139d..f86446ba81 100644 --- a/packages/drizzle-adapter/src/drizzle-adapter.ts +++ b/packages/drizzle-adapter/src/drizzle-adapter.ts @@ -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 => { 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; }; }; diff --git a/packages/drizzle-adapter/src/schema-check.test.ts b/packages/drizzle-adapter/src/schema-check.test.ts new file mode 100644 index 0000000000..39ded550b0 --- /dev/null +++ b/packages/drizzle-adapter/src/schema-check.test.ts @@ -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) { + const columns: Record> = { + 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 = {}; + 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, 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(); + }); +}); diff --git a/packages/drizzle-adapter/src/schema-check.ts b/packages/drizzle-adapter/src/schema-check.ts new file mode 100644 index 0000000000..ae6fe3125c --- /dev/null +++ b/packages/drizzle-adapter/src/schema-check.ts @@ -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, +): 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, + options: BetterAuthOptions, + usePlural?: boolean | undefined, +): SchemaFinding[] { + return diffSchema( + getExpectedSchema(options, { usePlural }), + introspectDrizzleSchema(schema), + ); +} diff --git a/packages/prisma-adapter/src/prisma-adapter.ts b/packages/prisma-adapter/src/prisma-adapter.ts index ada8fb4b00..80ea4e313e 100644 --- a/packages/prisma-adapter/src/prisma-adapter.ts +++ b/packages/prisma-adapter/src/prisma-adapter.ts @@ -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 => { 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; }; }; diff --git a/packages/prisma-adapter/src/schema-check.test.ts b/packages/prisma-adapter/src/schema-check.test.ts new file mode 100644 index 0000000000..4ac2d37f04 --- /dev/null +++ b/packages/prisma-adapter/src/schema-check.test.ts @@ -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(); + }); +}); diff --git a/packages/prisma-adapter/src/schema-check.ts b/packages/prisma-adapter/src/schema-check.ts new file mode 100644 index 0000000000..c6edb8dbfe --- /dev/null +++ b/packages/prisma-adapter/src/schema-check.ts @@ -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), + ); +}