/** * Detects how an application's database schema should be applied: which * engine owns it (Prisma ORM vs Prisma Next) and which CLI subcommand * brings the database in line with it (`migrate deploy`, `db push`, or the * Prisma Next init flow). * * Both deploy front doors share this so a git-push deploy and * `prisma app deploy` classify a repository identically. */ import type { Dirent } from "node:fs"; import { access, readdir, readFile, stat } from "node:fs/promises"; import path from "node:path"; export type SchemaEngine = "prisma-orm" | "prisma-next"; export type MigrationCommand = | "migrate-deploy" | "db-push" | "prisma-next-init"; export type UnsupportedSchemaTarget = | "mongodb" | "mysql" | "sqlite" | "sqlserver" | "cockroachdb"; export interface DetectedSchema { engine: SchemaEngine; /** Absolute path to `schema.prisma` (Prisma ORM) or the Prisma Next config. */ path: string; command: MigrationCommand; /** Prisma ORM only; always `false` for Prisma Next. */ hasMigrations: boolean; target: "postgresql" | "unknown"; } export interface UnsupportedSchema { engine: SchemaEngine; path: string; target: UnsupportedSchemaTarget; } export interface SchemaDetection { /** A schema whose migrations can be applied, or `null` if none was found. */ schema: DetectedSchema | null; /** A schema targeting an engine the migration flow does not support. */ unsupported: UnsupportedSchema | null; } const SKIPPED_DIRECTORIES = new Set([ ".git", ".next", ".nuxt", ".output", ".prisma", ".turbo", ".vercel", ".wrangler", "build", "coverage", "dist", "node_modules", "out", ]); const MAX_SCAN_DEPTH = 6; const MAX_SCAN_FILES = 1_000; const MAX_TEXT_FILE_BYTES = 1024 * 1024; /** * Walks `appPath` for a Prisma ORM schema or Prisma Next config and returns * the migration command that should run against it. Returns a `null` schema * when none is found, and reports a non-Postgres schema under `unsupported` * so callers can decide how to surface it. */ export async function detectAppSchema( appPath: string, signal?: AbortSignal, ): Promise { const state: ScanState = { filesVisited: 0, schemaCandidates: [], prismaNextConfigCandidates: [], }; await scanDirectory(appPath, 0, state, signal); const prismaNextConfigs = await Promise.all( state.prismaNextConfigCandidates.map((configPath) => classifyPrismaNextConfig(configPath, signal), ), ); const supportedPrismaNextConfig = selectPrismaNextConfig( appPath, prismaNextConfigs, "supported", ); const unsupportedPrismaNextConfig = selectPrismaNextConfig( appPath, prismaNextConfigs, "unsupported", ); const selectedPrismaOrmSchema = await selectPrismaOrmSchema( appPath, state.schemaCandidates, signal, ); const schema: DetectedSchema | null = supportedPrismaNextConfig ? { engine: "prisma-next", path: supportedPrismaNextConfig.path, hasMigrations: false, command: "prisma-next-init", target: supportedPrismaNextConfig.target, } : selectedPrismaOrmSchema.schema; const unsupported: UnsupportedSchema | null = schema ? null : unsupportedPrismaNextConfig ? { engine: "prisma-next", path: unsupportedPrismaNextConfig.path, target: unsupportedPrismaNextConfig.target, } : selectedPrismaOrmSchema.unsupported; return { schema, unsupported }; } interface ScanState { filesVisited: number; schemaCandidates: string[]; prismaNextConfigCandidates: string[]; } interface ClassifiedPrismaNextConfig { path: string; target: "postgresql" | "unknown" | UnsupportedSchemaTarget; } interface PrismaOrmSchemaSelection { schema: DetectedSchema | null; unsupported: UnsupportedSchema | null; } async function scanDirectory( directory: string, depth: number, state: ScanState, signal?: AbortSignal, ): Promise { signal?.throwIfAborted(); if (depth > MAX_SCAN_DEPTH || state.filesVisited >= MAX_SCAN_FILES) { return; } let entries: Dirent[]; try { entries = await readdir(directory, { withFileTypes: true }); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { return; } throw error; } entries.sort((left, right) => left.name.localeCompare(right.name)); for (const entry of entries) { signal?.throwIfAborted(); if (state.filesVisited >= MAX_SCAN_FILES) { return; } const entryPath = path.join(directory, entry.name); if (entry.isDirectory()) { if (!SKIPPED_DIRECTORIES.has(entry.name)) { await scanDirectory(entryPath, depth + 1, state, signal); } continue; } if (!entry.isFile()) { continue; } state.filesVisited += 1; if (entry.name === "schema.prisma") { state.schemaCandidates.push(entryPath); } if (isPrismaNextConfigFile(entry.name)) { state.prismaNextConfigCandidates.push(entryPath); } } } async function selectPrismaOrmSchema( appPath: string, candidates: string[], signal?: AbortSignal, ): Promise { const sorted = sortByPreferredRelativePath( appPath, candidates, "schema.prisma", ); for (const schemaPath of sorted) { const target = await classifyPrismaOrmSchemaTarget(schemaPath, signal); if (target === "postgresql" || target === "unknown") { const hasMigrations = await hasMigrationsDirectory( path.dirname(schemaPath), signal, ); return { schema: { engine: "prisma-orm", path: schemaPath, hasMigrations, command: hasMigrations ? "migrate-deploy" : "db-push", target, }, unsupported: null, }; } return { schema: null, unsupported: { engine: "prisma-orm", path: schemaPath, target }, }; } return { schema: null, unsupported: null }; } function selectPrismaNextConfig( appPath: string, candidates: ClassifiedPrismaNextConfig[], mode: "supported", ): { path: string; target: "postgresql" | "unknown" } | null; function selectPrismaNextConfig( appPath: string, candidates: ClassifiedPrismaNextConfig[], mode: "unsupported", ): { path: string; target: UnsupportedSchemaTarget } | null; function selectPrismaNextConfig( appPath: string, candidates: ClassifiedPrismaNextConfig[], mode: "supported" | "unsupported", ): ClassifiedPrismaNextConfig | null { const matches = candidates.filter((candidate) => { const isSupported = candidate.target === "postgresql" || candidate.target === "unknown"; return mode === "supported" ? isSupported : !isSupported; }); return ( sortByPreferredRelativePath( appPath, matches.map((candidate) => candidate.path), "prisma-next.config.ts", ) .map((candidatePath) => matches.find((candidate) => candidate.path === candidatePath), ) .find((candidate): candidate is ClassifiedPrismaNextConfig => Boolean(candidate), ) ?? null ); } function sortByPreferredRelativePath( appPath: string, candidates: string[], preferredRootFile: string, ): string[] { return candidates .map((candidate) => ({ absolute: candidate, relative: path.relative(appPath, candidate) || preferredRootFile, })) .sort((left, right) => { if (left.relative === preferredRootFile) return -1; if (right.relative === preferredRootFile) return 1; return ( left.relative.length - right.relative.length || left.relative.localeCompare(right.relative) ); }) .map((candidate) => candidate.absolute); } async function hasMigrationsDirectory( schemaDirectory: string, signal?: AbortSignal, ): Promise { signal?.throwIfAborted(); const migrationsPath = path.join(schemaDirectory, "migrations"); try { await access(migrationsPath); const entries = await readdir(migrationsPath); return entries.length > 0; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { return false; } throw error; } } async function classifyPrismaNextConfig( configPath: string, signal?: AbortSignal, ): Promise { const content = await readTextFileIfSmall(configPath, signal); if (!content) { return { path: configPath, target: "unknown" }; } if (content.includes("@prisma-next/postgres/config")) { return { path: configPath, target: "postgresql" }; } if (content.includes("@prisma-next/mongo/config")) { return { path: configPath, target: "mongodb" }; } if (content.includes("@prisma-next/sqlite/config")) { return { path: configPath, target: "sqlite" }; } return { path: configPath, target: "unknown" }; } async function classifyPrismaOrmSchemaTarget( schemaPath: string, signal?: AbortSignal, ): Promise<"postgresql" | "unknown" | UnsupportedSchemaTarget> { const content = await readTextFileIfSmall(schemaPath, signal); const provider = content?.match(/\bprovider\s*=\s*"([^"]+)"/)?.[1] ?? null; switch (provider) { case "postgresql": return "postgresql"; case "mongodb": return "mongodb"; case "mysql": return "mysql"; case "sqlite": return "sqlite"; case "sqlserver": return "sqlserver"; case "cockroachdb": return "cockroachdb"; default: return "unknown"; } } function isPrismaNextConfigFile(fileName: string): boolean { if (!fileName.startsWith("prisma-next.config.")) { return false; } return [".cjs", ".cts", ".js", ".mjs", ".mts", ".ts"].some((extension) => fileName.endsWith(extension), ); } async function readTextFileIfSmall( filePath: string, signal?: AbortSignal, ): Promise { signal?.throwIfAborted(); const info = await stat(filePath); if (info.size > MAX_TEXT_FILE_BYTES) { return null; } return readFile(filePath, { encoding: "utf8", signal }); }