From f9ac706602a7a825f80ead84ea1aa8ea2ef77bf6 Mon Sep 17 00:00:00 2001 From: "starslingdev[bot]" <248995740+starslingdev[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:15:24 +0900 Subject: [PATCH] perf(e2e): skip redundant prisma schema pushes (#9882) Co-authored-by: starslingdev[bot] <248995740+starslingdev[bot]@users.noreply.github.com> Co-authored-by: Taesu --- .../test/prisma-adapter/prisma.mysql.test.ts | 2 +- .../test/prisma-adapter/prisma.pg.test.ts | 2 +- .../test/prisma-adapter/prisma.sqlite.test.ts | 2 +- .../test/prisma-adapter/push-prisma-schema.ts | 133 +++++++++++++++--- 4 files changed, 117 insertions(+), 22 deletions(-) diff --git a/e2e/adapter/test/prisma-adapter/prisma.mysql.test.ts b/e2e/adapter/test/prisma-adapter/prisma.mysql.test.ts index f11d66eea1..faede5cd6d 100644 --- a/e2e/adapter/test/prisma-adapter/prisma.mysql.test.ts +++ b/e2e/adapter/test/prisma-adapter/prisma.mysql.test.ts @@ -33,7 +33,7 @@ const { execute } = await testAdapter({ const migrationCount = incrementMigrationCount(); await generateAuthConfigFile(options); await generatePrismaSchema(options, db, migrationCount, dialect); - await pushPrismaSchema(dialect); + await pushPrismaSchema(dialect, db); destroyPrismaClient({ migrationCount: migrationCount - 1, dialect }); }, tests: [ diff --git a/e2e/adapter/test/prisma-adapter/prisma.pg.test.ts b/e2e/adapter/test/prisma-adapter/prisma.pg.test.ts index edaa5d3b2c..5e6b271a39 100644 --- a/e2e/adapter/test/prisma-adapter/prisma.pg.test.ts +++ b/e2e/adapter/test/prisma-adapter/prisma.pg.test.ts @@ -35,7 +35,7 @@ const { execute } = await testAdapter({ const migrationCount = incrementMigrationCount(); await generateAuthConfigFile(options); await generatePrismaSchema(options, db, migrationCount, dialect); - await pushPrismaSchema(dialect); + await pushPrismaSchema(dialect, db); destroyPrismaClient({ migrationCount: migrationCount - 1, dialect }); }, tests: [ diff --git a/e2e/adapter/test/prisma-adapter/prisma.sqlite.test.ts b/e2e/adapter/test/prisma-adapter/prisma.sqlite.test.ts index 43635134e9..d80bcb69b5 100644 --- a/e2e/adapter/test/prisma-adapter/prisma.sqlite.test.ts +++ b/e2e/adapter/test/prisma-adapter/prisma.sqlite.test.ts @@ -33,7 +33,7 @@ const { execute } = await testAdapter({ const migrationCount = incrementMigrationCount(); await generateAuthConfigFile(options); await generatePrismaSchema(options, db, migrationCount, dialect); - await pushPrismaSchema(dialect); + await pushPrismaSchema(dialect, db); destroyPrismaClient({ migrationCount: migrationCount - 1, dialect }); }, tests: [ diff --git a/e2e/adapter/test/prisma-adapter/push-prisma-schema.ts b/e2e/adapter/test/prisma-adapter/push-prisma-schema.ts index 5027c11810..00c16396d5 100644 --- a/e2e/adapter/test/prisma-adapter/push-prisma-schema.ts +++ b/e2e/adapter/test/prisma-adapter/push-prisma-schema.ts @@ -1,4 +1,4 @@ -import { execSync } from "node:child_process"; +import { execFileSync } from "node:child_process"; import fs from "node:fs"; import { createRequire } from "node:module"; import { dirname, join } from "node:path"; @@ -9,6 +9,27 @@ import { DATABASE_URLS } from "./constants"; // so we can copy instead of running `prisma generate` again. const lastGeneratedDir = new Map(); +// Track the schema last pushed per dialect. Migrations run on every +// better-auth options change, but most of them produce a schema that is +// already in the database. Then a full `prisma db push --force-reset` (a CLI +// subprocess that drops and recreates every table) is not needed, and clearing +// the data in-process is enough for test isolation. +const lastPushedSchema = new Map(); + +// The raw query methods used for the data reset. Prisma omits `$transaction` +// from the client passed to a transaction callback. +type RawQueryClient = { + $queryRawUnsafe: ( + query: string, + ...values: unknown[] + ) => Promise; + $executeRawUnsafe: (query: string, ...values: unknown[]) => Promise; +}; + +type RawClient = RawQueryClient & { + $transaction: (fn: (tx: RawQueryClient) => Promise) => Promise; +}; + function resolvePrismaCli() { const require = createRequire(import.meta.url); return join( @@ -18,9 +39,51 @@ function resolvePrismaCli() { ); } -export async function pushPrismaSchema(dialect: Dialect) { +// Empties every table and resets its identity counter, which leaves the same +// state as `db push --force-reset` when the schema is unchanged. Throws on any +// failure so the caller can fall back to a full push. +async function truncateAllTables( + dialect: Dialect, + db: RawClient, +): Promise { + if (dialect === "postgresql") { + const rows = await db.$queryRawUnsafe>( + `SELECT tablename FROM pg_tables WHERE schemaname = 'public'`, + ); + const tables = rows.map((r) => `"${r.tablename}"`); + if (tables.length > 0) { + await db.$executeRawUnsafe( + `TRUNCATE TABLE ${tables.join(", ")} RESTART IDENTITY CASCADE`, + ); + } + return; + } + // The mariadb adapter pools connections, so the session-scoped + // FOREIGN_KEY_CHECKS=0 only holds inside an interactive transaction. TRUNCATE + // commits implicitly and would end that transaction, so rows are deleted + // with DELETE and the counters are reset after the transaction. + const tables = await db.$transaction(async (tx) => { + await tx.$executeRawUnsafe(`SET FOREIGN_KEY_CHECKS = 0`); + // The mariadb driver returns information_schema text columns as binary + // buffers, so the name is cast to CHAR. + const rows = await tx.$queryRawUnsafe>( + `SELECT CAST(table_name AS CHAR) AS name FROM information_schema.tables WHERE table_schema = DATABASE()`, + ); + const names = rows.map((r) => String(r.name)); + for (const table of names) { + await tx.$executeRawUnsafe(`DELETE FROM \`${table}\``); + } + await tx.$executeRawUnsafe(`SET FOREIGN_KEY_CHECKS = 1`); + return names; + }); + for (const table of tables) { + await db.$executeRawUnsafe(`ALTER TABLE \`${table}\` AUTO_INCREMENT = 1`); + } +} + +export async function pushPrismaSchema(dialect: Dialect, db?: RawClient) { const cwd = import.meta.dirname; - const cli = `${process.execPath} ${resolvePrismaCli()}`; + const prismaCli = resolvePrismaCli(); // Write a per-dialect prisma config file (Prisma v7 requires datasource url here) const configPath = join(cwd, `prisma-config-${dialect}.ts`); @@ -48,29 +111,61 @@ export default defineConfig({ } const outputDir = join(cwd, outputMatch[1]); + // When the schema is unchanged since the last push, only clear the data. + // sqlite pushes are cheap, so sqlite always does a full push. + let didReset = false; + if ( + db != null && + dialect !== "sqlite" && + lastPushedSchema.get(dialect) === schemaKey + ) { + try { + await truncateAllTables(dialect, db); + didReset = true; + } catch (error) { + console.warn( + `[pushPrismaSchema] data-reset fast-path failed for ${dialect}, falling back to full db push:`, + error, + ); + } + } + try { - execSync( - `${cli} db push --force-reset --accept-data-loss --config ${configPath}`, - { - stdio: "pipe", - cwd, - env: { - ...process.env, - // Prisma v7 blocks --force-reset when it detects an AI agent; this env var grants consent. - PRISMA_USER_CONSENT_FOR_DANGEROUS_AI_ACTION: - "I am running tests in a local development environment", + if (!didReset) { + execFileSync( + process.execPath, + [ + prismaCli, + "db", + "push", + "--force-reset", + "--accept-data-loss", + "--config", + configPath, + ], + { + stdio: "pipe", + cwd, + env: { + ...process.env, + // Prisma v7 blocks --force-reset when it detects an AI agent; this env var grants consent. + PRISMA_USER_CONSENT_FOR_DANGEROUS_AI_ACTION: + "I am running tests in a local development environment", + }, }, - }, - ); + ); + lastPushedSchema.set(dialect, schemaKey); + } const prevDir = lastGeneratedDir.get(schemaKey); if (prevDir && fs.existsSync(prevDir)) { fs.cpSync(prevDir, outputDir, { recursive: true }); } else { - execSync(`${cli} generate --config ${configPath}`, { - stdio: "pipe", - cwd, - }); + execFileSync( + process.execPath, + [prismaCli, "generate", "--config", configPath], + { stdio: "pipe", cwd }, + ); lastGeneratedDir.set(schemaKey, outputDir); } } catch (error) {