diff --git a/.changeset/quiet-lions-migrate.md b/.changeset/quiet-lions-migrate.md new file mode 100644 index 0000000000..c7c386b9e0 --- /dev/null +++ b/.changeset/quiet-lions-migrate.md @@ -0,0 +1,7 @@ +--- +"@better-auth/core": patch +"@better-auth/drizzle-adapter": patch +"@better-auth/prisma-adapter": patch +--- + +SQL migration tooling can now inspect and apply migrations through Drizzle and Prisma adapters. Migration queries share the adapter's configured dialect and transaction boundary, and schema inspection respects physical table and column names from Drizzle metadata and Prisma `@map` declarations. diff --git a/packages/core/src/db/adapter/index.ts b/packages/core/src/db/adapter/index.ts index 920639c750..5181a5591e 100644 --- a/packages/core/src/db/adapter/index.ts +++ b/packages/core/src/db/adapter/index.ts @@ -49,6 +49,62 @@ export type DBAdapterSchemaCreation = { overwrite?: boolean | undefined; }; +/** SQL dialect supported by Better Auth's migration tooling. */ +export type MigrationDatabaseDialect = + | "postgres" + | "mysql" + | "sqlite" + | "mssql"; + +/** Parameterized SQL statement executed by a migration connection. */ +export interface MigrationDatabaseQuery { + /** Values bound to the statement's parameter markers. */ + parameters: readonly unknown[]; + /** SQL text using the selected dialect's parameter markers. */ + sql: string; +} + +/** Normalized result returned by a migration query. */ +export interface MigrationDatabaseQueryResult { + /** Identifier generated by an insert, when the driver exposes one. */ + insertId?: bigint | undefined; + /** Number of rows changed by a write statement, when available. */ + numAffectedRows?: bigint | undefined; + /** Rows returned by a read statement. */ + rows: readonly Record[]; +} + +/** + * Parameterized SQL connection used only by Better Auth's migration tooling. + * + * Adapters keep their native database client private and expose this narrow + * capability so migration planning and execution can stay adapter-independent. + */ +export interface MigrationDatabaseConnection { + dialect: MigrationDatabaseDialect; + execute: ( + query: MigrationDatabaseQuery, + ) => Promise; + /** + * Resolve Better Auth model and field names to the SQL identifiers used by + * the adapter's configured schema. + */ + resolvePhysicalSchema?: + | ((schema: BetterAuthDBSchema) => Promise) + | undefined; + /** + * Run migration work through one transaction-scoped native connection. + * + * Adapters backed by a connection pool must pass a connection bound to the + * active transaction rather than reusing the root client. + */ + transaction?: + | (( + callback: (connection: MigrationDatabaseConnection) => Promise, + ) => Promise) + | undefined; +} + export interface DBAdapterFactoryConfig< Options extends BetterAuthOptions = BetterAuthOptions, > { @@ -78,6 +134,11 @@ export interface DBAdapterFactoryConfig< * Adapter id */ adapterId: string; + /** + * Optional SQL connection for deterministic migration inspection and + * execution. Non-SQL adapters should leave this undefined. + */ + migrationConnection?: MigrationDatabaseConnection | undefined; /** * If the database supports numeric ids, set this to `true`. * diff --git a/packages/drizzle-adapter/src/drizzle-adapter.test.ts b/packages/drizzle-adapter/src/drizzle-adapter.test.ts index 9e81306d63..2ed44e4cb3 100644 --- a/packages/drizzle-adapter/src/drizzle-adapter.test.ts +++ b/packages/drizzle-adapter/src/drizzle-adapter.test.ts @@ -1,3 +1,4 @@ +import type { BetterAuthDBSchema } from "@better-auth/core/db"; import { is, Param, SQL, sql } from "drizzle-orm"; import { boolean, @@ -10,6 +11,98 @@ import { describe, expect, it, vi } from "vitest"; import { drizzleAdapter } from "./drizzle-adapter"; describe("drizzle-adapter", () => { + it("resolves physical table and column names for migrations", async () => { + const account = pgTable("auth_account", { + accountId: text("account_id").notNull(), + issuer: text("identity_issuer").notNull(), + providerId: text("provider_id").notNull(), + }); + const options = {}; + const adapter = drizzleAdapter( + { _: { fullSchema: { account } } } as never, + { provider: "pg" }, + )(options); + + const accountSchema = { + modelName: "account", + fields: { + accountId: { type: "string", fieldName: "accountId" }, + providerId: { type: "string", fieldName: "providerId" }, + issuer: { type: "string", fieldName: "issuer" }, + }, + } satisfies BetterAuthDBSchema[string]; + const resolved = + await adapter.options?.adapterConfig.migrationConnection?.resolvePhysicalSchema?.( + { account: accountSchema }, + ); + + expect(resolved?.account?.modelName).toBe("auth_account"); + expect(resolved?.account?.fields.accountId?.fieldName).toBe("account_id"); + expect(resolved?.account?.fields.providerId?.fieldName).toBe("provider_id"); + expect(resolved?.account?.fields.issuer?.fieldName).toBe("identity_issuer"); + }); + + it("resolves a customized model through its schema key", async () => { + const account = pgTable("auth_account", { + accountId: text("account_id").notNull(), + }); + const adapter = drizzleAdapter( + { _: { fullSchema: { account } } } as never, + { provider: "pg" }, + )({}); + + const resolved = + await adapter.options?.adapterConfig.migrationConnection?.resolvePhysicalSchema?.( + { + account: { + modelName: "accounts", + fields: { + accountId: { type: "string", fieldName: "accountId" }, + }, + }, + }, + ); + + expect(resolved?.account?.modelName).toBe("auth_account"); + expect(resolved?.account?.fields.accountId?.fieldName).toBe("account_id"); + }); + + it("fails when the configured Drizzle schema is missing migration metadata", async () => { + const account = pgTable("auth_account", { + accountId: text("account_id").notNull(), + }); + const adapter = drizzleAdapter( + { _: { fullSchema: { account } } } as never, + { provider: "pg" }, + )({}); + const migrationConnection = + adapter.options?.adapterConfig.migrationConnection; + + await expect( + migrationConnection?.resolvePhysicalSchema?.({ + account: { + modelName: "account", + fields: { + accountId: { type: "string", fieldName: "accountId" }, + issuer: { type: "string", fieldName: "issuer" }, + }, + }, + }), + ).rejects.toThrow( + 'Drizzle migration schema could not resolve field "issuer" on model "account".', + ); + await expect( + migrationConnection?.resolvePhysicalSchema?.({ + jwks: { + modelName: "jwks", + fields: { id: { type: "string", fieldName: "id" } }, + }, + }), + ).rejects.toThrow( + 'Drizzle migration schema could not resolve model "jwks".', + ); + }); + it("should create drizzle adapter", () => { const db = { _: { @@ -23,6 +116,216 @@ describe("drizzle-adapter", () => { expect(adapter).toBeDefined(); }); + it("exposes a parameterized migration connection", async () => { + const all = vi + .fn() + .mockResolvedValue([{ providerId: "credential", count: 2 }]); + const run = vi.fn().mockResolvedValue({ changes: 2 }); + const adapter = drizzleAdapter( + { + _: { fullSchema: {} }, + all, + run, + } as never, + { provider: "sqlite" }, + )({ secret: "test-secret-that-is-at-least-32-chars-long!!" }); + const migrationConnection = + adapter.options?.adapterConfig.migrationConnection; + + expect(migrationConnection?.dialect).toBe("sqlite"); + await expect( + migrationConnection?.execute({ + parameters: ["credential"], + sql: "SELECT providerId, COUNT(*) AS count FROM account WHERE providerId = ?", + }), + ).resolves.toEqual({ + rows: [{ providerId: "credential", count: 2 }], + }); + const statement = all.mock.calls[0]?.[0]; + expect(is(statement, SQL)).toBe(true); + const containsCredentialParameter = (expression: unknown): boolean => { + if (expression === "credential") return true; + if (is(expression, Param)) return expression.value === "credential"; + if (!is(expression, SQL)) return false; + return expression.queryChunks.some(containsCredentialParameter); + }; + expect( + (statement as SQL).queryChunks.some(containsCredentialParameter), + ).toBe(true); + await expect( + migrationConnection?.execute({ + parameters: ["local:credential"], + sql: "UPDATE account SET issuer = ?", + }), + ).resolves.toEqual({ + numAffectedRows: 2n, + rows: [], + }); + expect(run).toHaveBeenCalledTimes(1); + }); + + it.each([ + { + name: "mysql2 tuple", + provider: "mysql" as const, + result: [{ affectedRows: 2 }, []], + }, + { + name: "node-postgres result", + provider: "pg" as const, + result: { rowCount: 2, rows: [] }, + }, + { + name: "postgres-js result", + provider: "pg" as const, + result: Object.assign([], { count: 2 }), + }, + ])("normalizes $name for migration writes", async ({ provider, result }) => { + const adapter = drizzleAdapter( + { + _: { fullSchema: {} }, + execute: vi.fn().mockResolvedValue(result), + } as never, + { provider }, + )({ secret: "test-secret-that-is-at-least-32-chars-long!!" }); + + await expect( + adapter.options?.adapterConfig.migrationConnection?.execute({ + parameters: ["local:credential"], + sql: + provider === "pg" + ? "UPDATE account SET issuer = $1" + : "UPDATE account SET issuer = ?", + }), + ).resolves.toEqual({ numAffectedRows: 2n, rows: [] }); + }); + + it.each([ + { + provider: "sqlite" as const, + sql: "SELECT '?' AS literal, providerId FROM account WHERE providerId = ? -- ?", + }, + { + provider: "pg" as const, + sql: "SELECT '$2' AS literal, $$ $3 $$ AS body FROM account WHERE providerId = $1 -- $4", + }, + ])("ignores parameter-like text in $provider SQL literals and comments", async ({ + provider, + sql: query, + }) => { + const rows = [{ providerId: "credential" }]; + const adapter = drizzleAdapter( + { + _: { fullSchema: {} }, + all: vi.fn().mockResolvedValue(rows), + execute: vi.fn().mockResolvedValue(rows), + } as never, + { provider }, + )({ secret: "test-secret-that-is-at-least-32-chars-long!!" }); + + await expect( + adapter.options?.adapterConfig.migrationConnection?.execute({ + parameters: ["credential"], + sql: query, + }), + ).resolves.toEqual({ rows }); + }); + + it("scopes SQLite migration queries to one transaction", async () => { + const statements: string[] = []; + const run = vi.fn().mockImplementation((statement: SQL) => { + statements.push( + statement.queryChunks + .flatMap((chunk) => + typeof chunk === "object" && + chunk !== null && + "value" in chunk && + Array.isArray(chunk.value) + ? chunk.value + : [], + ) + .join(""), + ); + return { changes: 1 }; + }); + const adapter = drizzleAdapter( + { + _: { fullSchema: {} }, + all: vi.fn(), + run, + } as never, + { provider: "sqlite", transaction: true }, + )({ secret: "test-secret-that-is-at-least-32-chars-long!!" }); + const migrationConnection = + adapter.options?.adapterConfig.migrationConnection; + + await migrationConnection?.transaction?.(async (connection) => { + await connection.execute({ + parameters: [], + sql: "UPDATE account SET issuer = 'local:credential'", + }); + }); + + expect(statements).toEqual([ + "SELECT 1", + "BEGIN IMMEDIATE", + "UPDATE account SET issuer = 'local:credential'", + "COMMIT", + ]); + }); + + it("uses a transaction-scoped database for asynchronous SQLite migrations", async () => { + const rootRun = vi.fn().mockResolvedValue({ changes: 0 }); + const transactionRun = vi.fn().mockResolvedValue({ changes: 1 }); + const transaction = vi.fn().mockImplementation(async (callback) => + callback({ + _: { fullSchema: {} }, + all: vi.fn(), + run: transactionRun, + }), + ); + const adapter = drizzleAdapter( + { + _: { fullSchema: {} }, + all: vi.fn(), + run: rootRun, + transaction, + } as never, + { provider: "sqlite", transaction: true }, + )({ secret: "test-secret-that-is-at-least-32-chars-long!!" }); + + await adapter.options?.adapterConfig.migrationConnection?.transaction?.( + async (connection) => { + await connection.execute({ + parameters: [], + sql: "UPDATE account SET issuer = 'local:credential'", + }); + }, + ); + + expect(transaction).toHaveBeenCalledOnce(); + expect(transactionRun).toHaveBeenCalledOnce(); + expect(rootRun).toHaveBeenCalledOnce(); + }); + + /** + * @see https://github.com/better-auth/better-auth/pull/10575#discussion_r3812054987 + */ + it("does not expose migration transactions when the adapter disables them", () => { + const adapter = drizzleAdapter( + { + _: { fullSchema: {} }, + all: vi.fn(), + run: vi.fn(), + } as never, + { provider: "sqlite", transaction: false }, + )({ secret: "test-secret-that-is-at-least-32-chars-long!!" }); + + expect( + adapter.options?.adapterConfig.migrationConnection?.transaction, + ).toBeUndefined(); + }); + it("should use unique column fallback for MySQL creates without an id", async () => { const userRow = { id: 42, diff --git a/packages/drizzle-adapter/src/drizzle-adapter.ts b/packages/drizzle-adapter/src/drizzle-adapter.ts index 924b1f139d..bdbaff4699 100644 --- a/packages/drizzle-adapter/src/drizzle-adapter.ts +++ b/packages/drizzle-adapter/src/drizzle-adapter.ts @@ -4,6 +4,10 @@ import type { AdapterFactoryOptions, DBAdapter, DBAdapterDebugLogOption, + MigrationDatabaseConnection, + MigrationDatabaseDialect, + MigrationDatabaseQuery, + MigrationDatabaseQueryResult, Where, } from "@better-auth/core/db/adapter"; import { createAdapterFactory } from "@better-auth/core/db/adapter"; @@ -17,6 +21,7 @@ import { count, desc, eq, + getTableName, gt, gte, inArray, @@ -101,6 +106,8 @@ function getAffectedRowCount( function readDriverRowCount(result: unknown): unknown { if (!result || typeof result !== "object") return undefined; const driverResult = result as Record; + if ("rowCount" in driverResult) return driverResult.rowCount; + if ("count" in driverResult) return driverResult.count; if ("affectedRows" in driverResult) return driverResult.affectedRows; if ("rowsAffected" in driverResult) return driverResult.rowsAffected; if ("changes" in driverResult) return driverResult.changes; @@ -121,6 +128,371 @@ function hasDriverRowCount(result: unknown): boolean { return readDriverRowCount(result) !== undefined; } +function isDriverResultHeader(result: unknown): boolean { + if (!result || typeof result !== "object") return false; + const driverResult = result as Record; + return ( + "affectedRows" in driverResult || + "rowsAffected" in driverResult || + "changes" in driverResult || + ("meta" in driverResult && + driverResult.meta !== null && + typeof driverResult.meta === "object" && + "changes" in driverResult.meta) + ); +} + +function getMigrationRows(result: unknown): readonly Record[] { + if (Array.isArray(result)) { + if (result.length > 0 && isDriverResultHeader(result[0])) return []; + if (Array.isArray(result[0])) { + return result[0].filter( + (row): row is Record => + typeof row === "object" && row !== null, + ); + } + return result.filter( + (row): row is Record => + typeof row === "object" && row !== null, + ); + } + if (!result || typeof result !== "object") return []; + if ("rows" in result && Array.isArray(result.rows)) { + return result.rows.filter( + (row): row is Record => + typeof row === "object" && row !== null, + ); + } + if ("results" in result && Array.isArray(result.results)) { + return result.results.filter( + (row): row is Record => + typeof row === "object" && row !== null, + ); + } + return []; +} + +interface MigrationParameterMarker { + length: number; + offset: number; + parameterIndex: number; +} + +function findMigrationParameterMarkers( + query: string, + dialect: MigrationDatabaseDialect, +): MigrationParameterMarker[] { + const markers: MigrationParameterMarker[] = []; + let offset = 0; + let positionalParameter = 0; + while (offset < query.length) { + const character = query[offset]; + const nextCharacter = query[offset + 1]; + + if (character === "-" && nextCharacter === "-") { + offset = query.indexOf("\n", offset + 2); + if (offset === -1) break; + continue; + } + if (dialect === "mysql" && character === "#") { + offset = query.indexOf("\n", offset + 1); + if (offset === -1) break; + continue; + } + if (character === "/" && nextCharacter === "*") { + let depth = 1; + offset += 2; + while (offset < query.length && depth > 0) { + if (query[offset] === "/" && query[offset + 1] === "*") { + depth += 1; + offset += 2; + continue; + } + if (query[offset] === "*" && query[offset + 1] === "/") { + depth -= 1; + offset += 2; + continue; + } + offset += 1; + } + continue; + } + if (character === "'" || character === '"' || character === "`") { + const quote = character; + offset += 1; + while (offset < query.length) { + if (dialect === "mysql" && query[offset] === "\\") { + offset += 2; + continue; + } + if (query[offset] !== quote) { + offset += 1; + continue; + } + if (query[offset + 1] === quote) { + offset += 2; + continue; + } + offset += 1; + break; + } + continue; + } + if (character === "[") { + offset += 1; + while (offset < query.length) { + if (query[offset] !== "]") { + offset += 1; + continue; + } + if (query[offset + 1] === "]") { + offset += 2; + continue; + } + offset += 1; + break; + } + continue; + } + if (dialect === "postgres" && character === "$") { + const dollarQuote = query + .slice(offset) + .match(/^\$(?:[A-Za-z_][A-Za-z0-9_]*)?\$/)?.[0]; + if (dollarQuote) { + const closingOffset = query.indexOf( + dollarQuote, + offset + dollarQuote.length, + ); + offset = + closingOffset === -1 + ? query.length + : closingOffset + dollarQuote.length; + continue; + } + const parameterMarker = query.slice(offset).match(/^\$(\d+)/); + if (parameterMarker) { + markers.push({ + length: parameterMarker[0].length, + offset, + parameterIndex: Number.parseInt(parameterMarker[1] ?? "", 10) - 1, + }); + offset += parameterMarker[0].length; + continue; + } + } + if (dialect !== "postgres" && character === "?") { + markers.push({ length: 1, offset, parameterIndex: positionalParameter }); + positionalParameter += 1; + } + offset += 1; + } + return markers; +} + +function buildMigrationStatement( + query: MigrationDatabaseQuery, + dialect: MigrationDatabaseDialect, +): SQL { + if (query.parameters.length === 0) return sql.raw(query.sql); + const markers = findMigrationParameterMarkers(query.sql, dialect); + const chunks: SQL[] = []; + let sqlOffset = 0; + for (const marker of markers) { + chunks.push(sql.raw(query.sql.slice(sqlOffset, marker.offset))); + const parameterIndex = marker.parameterIndex; + if ( + !Number.isInteger(parameterIndex) || + parameterIndex < 0 || + parameterIndex >= query.parameters.length + ) { + throw new BetterAuthError( + "Drizzle migration query has an invalid parameter marker.", + ); + } + chunks.push(sql`${query.parameters[parameterIndex]}`); + sqlOffset = marker.offset + marker.length; + } + chunks.push(sql.raw(query.sql.slice(sqlOffset))); + if ( + (dialect === "postgres" + ? new Set(markers.map(({ parameterIndex }) => parameterIndex)).size + : markers.length) !== query.parameters.length + ) { + throw new BetterAuthError( + "Drizzle migration query parameter count does not match its SQL markers.", + ); + } + return sql.join(chunks, sql.raw("")); +} + +function getMigrationQueryResult( + driverResult: unknown, + isRead: boolean, +): MigrationDatabaseQueryResult { + if (isRead) return { rows: getMigrationRows(driverResult) }; + const affectedRows = + Array.isArray(driverResult) && driverResult.length > 0 + ? (readDriverRowCount(driverResult[0]) ?? + readDriverRowCount(driverResult)) + : readDriverRowCount(driverResult); + return { + rows: getMigrationRows(driverResult), + ...(typeof affectedRows === "number" && Number.isFinite(affectedRows) + ? { numAffectedRows: BigInt(affectedRows) } + : {}), + }; +} + +function isDrizzleMigrationRead(query: MigrationDatabaseQuery): boolean { + const sql = query.sql.trimStart(); + if (/^(?:select|with|explain)\b/i.test(sql)) return true; + return /^pragma\b/i.test(sql) && !/^pragma\b[^;]*=/i.test(sql); +} + +function isPromiseLike(value: unknown): value is PromiseLike { + return ( + value !== null && + (typeof value === "object" || typeof value === "function") && + "then" in value && + typeof value.then === "function" + ); +} + +function createDrizzleMigrationConnection( + db: DB, + dialect: MigrationDatabaseDialect, + drizzleSchema: Record | undefined, + inTransaction = false, + supportsTransactions = true, +): MigrationDatabaseConnection { + const connection: MigrationDatabaseConnection = { + dialect, + async resolvePhysicalSchema(schema) { + if (!drizzleSchema) { + throw new BetterAuthError( + "Drizzle migration schema resolution requires a schema object.", + ); + } + return Object.fromEntries( + Object.entries(schema).map(([schemaKey, table]) => { + const drizzleTable = + drizzleSchema[schemaKey] ?? drizzleSchema[table.modelName]; + if (!drizzleTable || typeof drizzleTable !== "object") { + throw new BetterAuthError( + `Drizzle migration schema could not resolve model "${table.modelName}".`, + ); + } + const fields = Object.fromEntries( + Object.entries(table.fields).map(([fieldKey, field]) => { + const drizzleFieldName = field.fieldName || fieldKey; + const drizzleTableFields = drizzleTable as Record< + string, + unknown + >; + const column = + drizzleTableFields[fieldKey] ?? + drizzleTableFields[drizzleFieldName]; + if (!is(column, Column)) { + throw new BetterAuthError( + `Drizzle migration schema could not resolve field "${drizzleFieldName}" on model "${table.modelName}".`, + ); + } + return [ + fieldKey, + { + ...field, + fieldName: column.name, + }, + ]; + }), + ); + return [ + schemaKey, + { + ...table, + modelName: getTableName(drizzleTable as never), + fields, + }, + ]; + }), + ); + }, + async execute(query) { + const statement = buildMigrationStatement(query, dialect); + const isRead = isDrizzleMigrationRead(query); + const driverResult = + dialect === "sqlite" + ? isRead + ? await db.all(statement) + : await db.run(statement) + : await db.execute(statement); + return getMigrationQueryResult(driverResult, isRead); + }, + }; + if (inTransaction || !supportsTransactions) return connection; + connection.transaction = async (callback) => { + if (dialect === "sqlite") { + const probe = db.run(sql.raw("SELECT 1")); + if (isPromiseLike(probe)) { + await probe; + if (typeof db.transaction !== "function") { + throw new BetterAuthError( + "Drizzle migration transactions for asynchronous SQLite drivers require a native transaction-scoped database.", + ); + } + return db.transaction((transactionDatabase: DB) => + callback( + createDrizzleMigrationConnection( + transactionDatabase, + dialect, + drizzleSchema, + true, + true, + ), + ), + ); + } + await connection.execute({ + parameters: [], + sql: "BEGIN IMMEDIATE", + }); + try { + const result = await callback( + createDrizzleMigrationConnection( + db, + dialect, + drizzleSchema, + true, + true, + ), + ); + await connection.execute({ parameters: [], sql: "COMMIT" }); + return result; + } catch (error) { + await connection.execute({ parameters: [], sql: "ROLLBACK" }); + throw error; + } + } + if (typeof db.transaction !== "function") { + throw new BetterAuthError( + `Drizzle does not expose transactions for the ${dialect} migration connection.`, + ); + } + return db.transaction((transactionDatabase: DB) => + callback( + createDrizzleMigrationConnection( + transactionDatabase, + dialect, + drizzleSchema, + true, + true, + ), + ), + ); + }; + return connection; +} + export interface DrizzleAdapterConfig { /** * The schema object that defines the tables and fields @@ -1167,6 +1539,13 @@ export const drizzleAdapter = (db: DB, config: DrizzleAdapterConfig) => { config: { adapterId: "drizzle", adapterName: "Drizzle Adapter", + migrationConnection: createDrizzleMigrationConnection( + db, + config.provider === "pg" ? "postgres" : config.provider, + config.schema || db._?.fullSchema, + false, + config.transaction === true, + ), usePlural: config.usePlural ?? false, debugLogs: config.debugLogs ?? false, supportsUUIDs: config.provider === "pg" ? true : false, diff --git a/packages/prisma-adapter/src/prisma-adapter.test.ts b/packages/prisma-adapter/src/prisma-adapter.test.ts index d0a5652f03..ece1858e28 100644 --- a/packages/prisma-adapter/src/prisma-adapter.test.ts +++ b/packages/prisma-adapter/src/prisma-adapter.test.ts @@ -1,8 +1,89 @@ import type { BetterAuthOptions } from "@better-auth/core"; +import type { BetterAuthDBSchema } from "@better-auth/core/db"; import { describe, expect, it, vi } from "vitest"; import { prismaAdapter } from "./prisma-adapter"; describe("prisma-adapter", () => { + it("resolves Prisma model and field mappings for migrations", async () => { + const options = {}; + const adapter = prismaAdapter( + { + $transaction: vi.fn(), + _runtimeDataModel: { + models: { + Account: { + dbName: "auth_account", + fields: [ + { name: "accountId", dbName: "account_id" }, + { name: "providerId", dbName: "provider_id" }, + { name: "issuer", dbName: "identity_issuer" }, + ], + }, + }, + }, + } as never, + { provider: "sqlite" }, + )(options); + + const accountSchema = { + modelName: "account", + fields: { + accountId: { type: "string", fieldName: "accountId" }, + providerId: { type: "string", fieldName: "providerId" }, + issuer: { type: "string", fieldName: "issuer" }, + }, + } satisfies BetterAuthDBSchema[string]; + const resolved = + await adapter.options?.adapterConfig.migrationConnection?.resolvePhysicalSchema?.( + { account: accountSchema }, + ); + + expect(resolved?.account?.modelName).toBe("auth_account"); + expect(resolved?.account?.fields.accountId?.fieldName).toBe("account_id"); + expect(resolved?.account?.fields.providerId?.fieldName).toBe("provider_id"); + expect(resolved?.account?.fields.issuer?.fieldName).toBe("identity_issuer"); + }); + + it("keeps declared names for schema additions missing from the Prisma model", async () => { + const adapter = prismaAdapter( + { + $transaction: vi.fn(), + _runtimeDataModel: { + models: { + Account: { + dbName: "auth_account", + fields: [{ name: "id", dbName: "account_id" }], + }, + }, + }, + } as never, + { provider: "sqlite" }, + )({}); + + const resolved = + await adapter.options?.adapterConfig.migrationConnection?.resolvePhysicalSchema?.( + { + account: { + modelName: "account", + fields: { + id: { type: "string", fieldName: "id" }, + issuer: { type: "string", fieldName: "issuer" }, + }, + }, + jwks: { + modelName: "jwks", + fields: { id: { type: "string", fieldName: "id" } }, + }, + }, + ); + + expect(resolved?.account?.modelName).toBe("auth_account"); + expect(resolved?.account?.fields.id?.fieldName).toBe("account_id"); + expect(resolved?.account?.fields.issuer?.fieldName).toBe("issuer"); + expect(resolved?.jwks?.modelName).toBe("jwks"); + expect(resolved?.jwks?.fields.id?.fieldName).toBe("id"); + }); + const createTestAdapter = (prisma: Record) => prismaAdapter(prisma as never, { provider: "sqlite", @@ -32,6 +113,116 @@ describe("prisma-adapter", () => { expect(adapter).toBeDefined(); }); + it("exposes a parameterized migration connection", async () => { + const queryRaw = vi + .fn() + .mockResolvedValue([{ providerId: "credential", count: 2 }]); + const executeRaw = vi.fn().mockResolvedValue(2); + const adapter = createTestAdapter({ + $executeRawUnsafe: executeRaw, + $queryRawUnsafe: queryRaw, + $transaction: vi.fn(), + }); + const migrationConnection = + adapter.options?.adapterConfig.migrationConnection; + + expect(migrationConnection?.dialect).toBe("sqlite"); + await expect( + migrationConnection?.execute({ + parameters: ["credential"], + sql: "SELECT providerId, COUNT(*) AS count FROM account WHERE providerId = ?", + }), + ).resolves.toEqual({ + rows: [{ providerId: "credential", count: 2 }], + }); + await expect( + migrationConnection?.execute({ + parameters: ["issuer"], + sql: "UPDATE account SET issuer = ?", + }), + ).resolves.toEqual({ + numAffectedRows: 2n, + rows: [], + }); + expect(queryRaw).toHaveBeenCalledWith( + "SELECT providerId, COUNT(*) AS count FROM account WHERE providerId = ?", + "credential", + ); + expect(executeRaw).toHaveBeenCalledWith( + "UPDATE account SET issuer = ?", + "issuer", + ); + }); + + it("uses Prisma's transaction-scoped client for migration queries", async () => { + const rootExecuteRaw = vi.fn(); + const transactionExecuteRaw = vi.fn().mockResolvedValue(1); + const transactionClient = { + $executeRawUnsafe: transactionExecuteRaw, + $queryRawUnsafe: vi.fn(), + }; + const transaction = vi + .fn() + .mockImplementation(async (callback) => callback(transactionClient)); + const adapter = createTestAdapter({ + $executeRawUnsafe: rootExecuteRaw, + $queryRawUnsafe: vi.fn(), + $transaction: transaction, + }); + const migrationConnection = + adapter.options?.adapterConfig.migrationConnection; + + await migrationConnection?.transaction?.(async (connection) => { + await connection.execute({ + parameters: ["local:credential"], + sql: "UPDATE account SET issuer = ?", + }); + }); + + expect(transaction).toHaveBeenCalledWith(expect.any(Function), { + maxWait: 60_000, + timeout: 600_000, + }); + expect(transactionExecuteRaw).toHaveBeenCalledWith( + "UPDATE account SET issuer = ?", + "local:credential", + ); + expect(rootExecuteRaw).not.toHaveBeenCalled(); + }); + + /** + * @see https://github.com/better-auth/better-auth/pull/10575#pullrequestreview-4970996114 + */ + it("passes migration transaction timeouts to Prisma", async () => { + const transactionClient = { + $executeRawUnsafe: vi.fn(), + $queryRawUnsafe: vi.fn(), + }; + const transaction = vi + .fn() + .mockImplementation(async (callback) => callback(transactionClient)); + const adapter = prismaAdapter( + { + $executeRawUnsafe: vi.fn(), + $queryRawUnsafe: vi.fn(), + $transaction: transaction, + } as never, + { + migrationTransaction: { maxWait: 12_000, timeout: 420_000 }, + provider: "sqlite", + }, + )({} as BetterAuthOptions); + + await adapter.options?.adapterConfig.migrationConnection?.transaction?.( + async () => undefined, + ); + + expect(transaction).toHaveBeenCalledWith(expect.any(Function), { + maxWait: 12_000, + timeout: 420_000, + }); + }); + /** * @see https://github.com/better-auth/better-auth/issues/8365 */ diff --git a/packages/prisma-adapter/src/prisma-adapter.ts b/packages/prisma-adapter/src/prisma-adapter.ts index ada8fb4b00..7c2a9a59a2 100644 --- a/packages/prisma-adapter/src/prisma-adapter.ts +++ b/packages/prisma-adapter/src/prisma-adapter.ts @@ -5,11 +5,30 @@ import type { DBAdapter, DBAdapterDebugLogOption, JoinConfig, + MigrationDatabaseConnection, + MigrationDatabaseDialect, + MigrationDatabaseQueryResult, Where, } from "@better-auth/core/db/adapter"; import { createAdapterFactory } from "@better-auth/core/db/adapter"; import { BetterAuthError } from "@better-auth/core/error"; +/** Controls the Prisma interactive transaction used by release migrations. */ +export interface PrismaMigrationTransactionConfig { + /** + * Maximum time to wait for Prisma to acquire the transaction. + * + * @default 60000 + */ + maxWait?: number | undefined; + /** + * Maximum time the release migration may run inside the transaction. + * + * @default 600000 + */ + timeout?: number | undefined; +} + export interface PrismaConfig { /** * Database provider. @@ -44,6 +63,13 @@ export interface PrismaConfig { * @default false */ transaction?: boolean | undefined; + + /** + * Interactive transaction limits for a Better Auth release migration. + * + * These limits do not change normal adapter transactions. + */ + migrationTransaction?: PrismaMigrationTransactionConfig | undefined; } interface PrismaClient {} @@ -59,9 +85,19 @@ function isPrismaNotFoundError(e: any): boolean { } type PrismaClientInternal = { - $transaction: ( - callback: (db: PrismaClient) => Awaitable, - ) => Promise; + $transaction: ( + callback: (db: PrismaClient) => Awaitable, + options?: PrismaMigrationTransactionConfig, + ) => Promise; + $executeRawUnsafe?: ( + query: string, + ...parameters: readonly unknown[] + ) => Promise; + $queryRawUnsafe?: ( + query: string, + ...parameters: readonly unknown[] + ) => Promise; + _runtimeDataModel?: PrismaRuntimeDataModel | undefined; } & { [model: string]: { create: (data: any) => Promise; @@ -74,8 +110,154 @@ type PrismaClientInternal = { }; }; +type PrismaRuntimeDataModel = { + models: Record< + string, + { + dbName?: string | null | undefined; + fields: Array<{ + dbName?: string | null | undefined; + name: string; + }>; + } + >; +}; + +const defaultMigrationTransaction = { + maxWait: 60_000, + timeout: 600_000, +} satisfies Required; + +function getPrismaMigrationDialect( + provider: PrismaConfig["provider"], +): MigrationDatabaseDialect | undefined { + if (provider === "postgresql" || provider === "cockroachdb") + return "postgres"; + if (provider === "sqlserver") return "mssql"; + if (provider === "mysql" || provider === "sqlite") return provider; + return undefined; +} + +function isReadMigrationQuery(query: string): boolean { + return /^\s*(?:SELECT|WITH|PRAGMA|SHOW|EXPLAIN|DESCRIBE)\b/i.test(query); +} + +function getPrismaMigrationRows( + rows: unknown, +): readonly Record[] { + if (!Array.isArray(rows)) return []; + return rows.filter( + (row): row is Record => + typeof row === "object" && row !== null, + ); +} + +function createPrismaMigrationConnection( + prisma: PrismaClientInternal, + provider: PrismaConfig["provider"], + migrationTransaction: PrismaMigrationTransactionConfig, + inTransaction = false, + runtimeDataModel = prisma._runtimeDataModel, +): MigrationDatabaseConnection | undefined { + const dialect = getPrismaMigrationDialect(provider); + if (!dialect) return undefined; + const connection: MigrationDatabaseConnection = { + dialect, + async resolvePhysicalSchema(schema) { + if (!runtimeDataModel) { + throw new BetterAuthError( + "Prisma migration schema resolution requires Prisma runtime model metadata.", + ); + } + return Object.fromEntries( + Object.entries(schema).map(([schemaKey, table]) => { + const modelEntry = Object.entries(runtimeDataModel.models).find( + ([modelName, model]) => + modelName === table.modelName || + modelName.toLowerCase() === table.modelName.toLowerCase() || + model.dbName === table.modelName, + ); + if (!modelEntry) return [schemaKey, table]; + const [modelName, model] = modelEntry; + const fields = Object.fromEntries( + Object.entries(table.fields).map(([fieldKey, field]) => { + const prismaFieldName = field.fieldName || fieldKey; + const prismaField = model.fields.find( + (candidate) => candidate.name === prismaFieldName, + ); + return [ + fieldKey, + { + ...field, + fieldName: + prismaField?.dbName || prismaField?.name || prismaFieldName, + }, + ]; + }), + ); + return [ + schemaKey, + { + ...table, + modelName: model.dbName || modelName, + fields, + }, + ]; + }), + ); + }, + async execute(query): Promise { + if (isReadMigrationQuery(query.sql)) { + if (!prisma.$queryRawUnsafe) { + throw new BetterAuthError( + "Prisma migration inspection requires $queryRawUnsafe on the Prisma client.", + ); + } + const rows = await prisma.$queryRawUnsafe( + query.sql, + ...query.parameters, + ); + return { rows: getPrismaMigrationRows(rows) }; + } + if (!prisma.$executeRawUnsafe) { + throw new BetterAuthError( + "Prisma migration execution requires $executeRawUnsafe on the Prisma client.", + ); + } + const affectedRows = await prisma.$executeRawUnsafe( + query.sql, + ...query.parameters, + ); + return { numAffectedRows: BigInt(affectedRows), rows: [] }; + }, + }; + if (!inTransaction) { + connection.transaction = async (callback) => + prisma.$transaction(async (transactionClient) => { + const transactionConnection = createPrismaMigrationConnection( + transactionClient as PrismaClientInternal, + provider, + migrationTransaction, + true, + runtimeDataModel, + ); + if (!transactionConnection) { + throw new BetterAuthError( + `Prisma does not expose a SQL migration connection for ${provider}.`, + ); + } + return callback(transactionConnection); + }, migrationTransaction); + } + return connection; +} + export const prismaAdapter = (prisma: PrismaClient, config: PrismaConfig) => { let lazyOptions: BetterAuthOptions | null = null; + const migrationTransaction = { + ...defaultMigrationTransaction, + ...config.migrationTransaction, + }; const createCustomAdapter = ( prisma: PrismaClient, @@ -796,6 +978,11 @@ export const prismaAdapter = (prisma: PrismaClient, config: PrismaConfig) => { config: { adapterId: "prisma", adapterName: "Prisma Adapter", + migrationConnection: createPrismaMigrationConnection( + prisma as PrismaClientInternal, + config.provider, + migrationTransaction, + ), usePlural: config.usePlural ?? false, debugLogs: config.debugLogs ?? false, supportsUUIDs: config.provider === "postgresql" ? true : false,