/** * Applies an application's database schema against a database, owning the * prisma CLI resolution so every deploy front door (git-push build-runner, * `prisma app deploy`) runs migrations the same way. * * The resolver locates the project's own prisma binary by walking * `node_modules` from the schema directory up to the source root, the way * Node resolves a dependency. That covers npm, pnpm, yarn, and monorepo * layouts where prisma is installed but the flat `node_modules/.bin/prisma` * symlink is absent at the app root. */ import { spawn } from "node:child_process"; import { readFile } from "node:fs/promises"; import path from "node:path"; import type { Readable } from "node:stream"; import { sourceRootLineage } from "./config/source-root.ts"; import type { DetectedSchema, MigrationCommand, SchemaEngine, } from "./detect-schema.ts"; import { detectAppSchema } from "./detect-schema.ts"; import { BuildError } from "./errors.ts"; export interface ApplyMigrationsOptions { /** Application root the build runs in; also the CLI working directory. */ appPath: string; /** Connection used for migrations: the direct/admin database URL. */ databaseUrl: string; /** Auto-detected from `appPath` when omitted. */ schema?: DetectedSchema; /** Extra environment for the CLI child, e.g. a user-set `DIRECT_URL`. */ env?: Record; signal?: AbortSignal; onLog?: (line: string, stream: "stdout" | "stderr") => void; } export type ApplyMigrationsResult = | { applied: true; via: MigrationCommand } | { applied: false; reason: "no-schema" }; /** * Last resort for repos that ship a schema with no prisma installed at all. * Pinned to the 6.x line: Prisma 7 rejects the classic `url = env(...)` * datasource form (P1012), which is exactly the schema shape such repos have. * Bump deliberately, never to `latest`. */ export const FALLBACK_PRISMA_CLI_VERSION = "6.19.3"; export interface CliLauncher { command: string; argsPrefix: string[]; /** Human-readable command prefix used in failure messages. */ display: string; } /** * Applies the app's schema and returns how it was applied, or `no-schema` * when the app has none. Throws {@link BuildError} on failure: a non-zero * CLI exit carries `exited with code N` (a user-fixable error), while an * unrunnable launcher does not (an infrastructure error). */ export async function applyMigrations( opts: ApplyMigrationsOptions, ): Promise { opts.signal?.throwIfAborted(); const schema = opts.schema ?? (await detectAppSchema(opts.appPath, opts.signal)).schema; if (!schema) { return { applied: false, reason: "no-schema" }; } const launchers = await resolveSchemaEngineCli( schema, opts.appPath, opts.signal, ); const relativeSchemaPath = path.relative(opts.appPath, schema.path) || defaultSchemaSourcePath(schema.engine); const env = { ...process.env, ...opts.env, DATABASE_URL: opts.databaseUrl, // Update nags pollute the migration log and failure snippets. PRISMA_HIDE_UPDATE_MESSAGE: "1", }; for (const subcommand of subcommandsFor( schema, relativeSchemaPath, opts.databaseUrl, )) { await runWithLaunchers({ launchers, subcommand: subcommand.args, subcommandDisplay: subcommand.display, cwd: opts.appPath, env, signal: opts.signal, onLog: opts.onLog, }); } return { applied: true, via: schema.command }; } /** * Resolves the launcher ladder for a schema's engine: the project's own * binary first (run via `node`), then download fallbacks tried in order when * the binary is not installed. */ export async function resolveSchemaEngineCli( schema: DetectedSchema, appPath: string, signal?: AbortSignal, ): Promise { const startDir = path.dirname(schema.path) || appPath; if (schema.engine === "prisma-next") { const localBin = await resolveLocalCliBin( startDir, "@prisma-next/cli", "prisma-next", signal, ); if (localBin) { return [ { command: "node", argsPrefix: [localBin], display: "prisma-next" }, ]; } // `@prisma-next/cli` is not published to npm, so there is no download // fallback; `--no-install` keeps today's behaviour when it is missing. return [ { command: "npx", argsPrefix: ["--no-install", "prisma-next"], display: "npx --no-install prisma-next", }, ]; } const localBin = await resolveLocalCliBin( startDir, "prisma", "prisma", signal, ); if (localBin) { return [{ command: "node", argsPrefix: [localBin], display: "prisma" }]; } const pinned = (await readResolvedPrismaVersion(startDir, signal)) ?? FALLBACK_PRISMA_CLI_VERSION; return [ { command: "npx", argsPrefix: ["--yes", "--package", `prisma@${pinned}`, "prisma"], display: `npx prisma@${pinned}`, }, { command: "bunx", argsPrefix: [`prisma@${pinned}`], display: `bunx prisma@${pinned}`, }, ]; } async function resolveLocalCliBin( startDir: string, packageName: string, binName: string, signal?: AbortSignal, ): Promise { for (const dir of await sourceRootLineage(startDir, signal)) { const packageDir = path.join(dir, "node_modules", packageName); const bin = await readBinFromPackageJson( path.join(packageDir, "package.json"), binName, signal, ); if (bin) { return path.resolve(packageDir, bin); } } return null; } async function readBinFromPackageJson( packageJsonPath: string, binName: string, signal?: AbortSignal, ): Promise { let raw: string; try { raw = await readFile(packageJsonPath, { encoding: "utf8", signal }); } catch (error) { if (signal?.aborted) throw error; return null; } let parsed: unknown; try { parsed = JSON.parse(raw); } catch { return null; } const bin = (parsed as { bin?: unknown }).bin; if (typeof bin === "string") { return bin; } if (bin && typeof bin === "object") { const entry = (bin as Record)[binName]; return typeof entry === "string" ? entry : null; } return null; } async function readResolvedPrismaVersion( startDir: string, signal?: AbortSignal, ): Promise { for (const dir of await sourceRootLineage(startDir, signal)) { const version = await readPackageVersion( path.join(dir, "node_modules", "@prisma", "client", "package.json"), signal, ); if (version) { return version; } } return null; } async function readPackageVersion( packageJsonPath: string, signal?: AbortSignal, ): Promise { let raw: string; try { raw = await readFile(packageJsonPath, { encoding: "utf8", signal }); } catch (error) { if (signal?.aborted) throw error; return null; } try { const parsed: unknown = JSON.parse(raw); const version = (parsed as { version?: unknown }).version; return typeof version === "string" && version.length > 0 ? version : null; } catch { return null; } } function subcommandsFor( schema: DetectedSchema, relativeSchemaPath: string, databaseUrl: string, ): Array<{ args: string[]; display: string }> { switch (schema.command) { case "migrate-deploy": return [ { args: ["migrate", "deploy", "--schema", relativeSchemaPath], display: "migrate deploy", }, ]; case "db-push": return [ { args: ["db", "push", "--schema", relativeSchemaPath], display: "db push", }, ]; case "prisma-next-init": return [ { args: ["contract", "emit", "--config", relativeSchemaPath], display: "contract emit", }, { args: [ "db", "init", "--config", relativeSchemaPath, "--db", databaseUrl, ], display: "db init", }, ]; default: { const exhaustive: never = schema.command; throw new BuildError({ message: `Unknown migration command: ${String(exhaustive)}`, }); } } } function defaultSchemaSourcePath(engine: SchemaEngine): string { return engine === "prisma-next" ? "prisma-next.config.ts" : "schema.prisma"; } type LauncherOutcome = | { kind: "ok" } | { kind: "unavailable" } | { kind: "failed"; code: number }; /** * Runs `subcommand` against the launcher ladder, falling through to the next * launcher when one cannot run (spawn ENOENT or exit 127) and throwing on the * first launcher that runs the tool to a real non-zero exit. */ export async function runWithLaunchers(opts: { launchers: CliLauncher[]; subcommand: string[]; subcommandDisplay: string; cwd: string; env: NodeJS.ProcessEnv; signal?: AbortSignal; onLog?: (line: string, stream: "stdout" | "stderr") => void; }): Promise { for (const launcher of opts.launchers) { opts.signal?.throwIfAborted(); const outcome = await runOnce(launcher, opts); if (outcome.kind === "ok") { return; } if (outcome.kind === "failed") { throw new BuildError({ message: `${launcher.display} ${opts.subcommandDisplay} exited with code ${outcome.code}`, }); } } throw new BuildError({ message: `Could not run ${opts.subcommandDisplay}: no prisma launcher was runnable (tried ${opts.launchers .map((launcher) => launcher.display) .join(", ")})`, }); } function runOnce( launcher: CliLauncher, opts: { subcommand: string[]; cwd: string; env: NodeJS.ProcessEnv; signal?: AbortSignal; onLog?: (line: string, stream: "stdout" | "stderr") => void; }, ): Promise { return new Promise((resolve, reject) => { const child = spawn( launcher.command, [...launcher.argsPrefix, ...opts.subcommand], { cwd: opts.cwd, env: opts.env, signal: opts.signal, stdio: ["ignore", "pipe", "pipe"], }, ); const flushStdout = streamLines(child.stdout, "stdout", opts.onLog); const flushStderr = streamLines(child.stderr, "stderr", opts.onLog); child.once("error", (error: NodeJS.ErrnoException) => { if (opts.signal?.aborted) { reject(error); return; } // A launcher that is not installed (e.g. `bunx` absent) should fall // through to the next, not surface as a migration failure. if (error.code === "ENOENT") { resolve({ kind: "unavailable" }); return; } reject( new BuildError({ message: `Failed to start ${launcher.display}: ${error.message}`, }), ); }); child.once("close", (code, terminationSignal) => { flushStdout(); flushStderr(); if (terminationSignal) { if (opts.signal?.aborted) { reject(opts.signal.reason ?? new Error("aborted")); return; } reject( new BuildError({ message: `${launcher.display} was terminated by ${terminationSignal}`, }), ); return; } if (code === 0) { resolve({ kind: "ok" }); return; } // Exit 127 is the shell's "command not found": the launcher could not // exec the tool. Treat it like ENOENT and try the next launcher rather // than blaming the user's schema. if (code === 127) { resolve({ kind: "unavailable" }); return; } resolve({ kind: "failed", code: code ?? 1 }); }); }); } function streamLines( stream: Readable | null, channel: "stdout" | "stderr", onLog?: (line: string, stream: "stdout" | "stderr") => void, ): () => void { if (!stream) { return () => {}; } let buffer = ""; stream.on("data", (chunk: Buffer) => { buffer += chunk.toString("utf8"); let newline = buffer.indexOf("\n"); while (newline !== -1) { const line = buffer.slice(0, newline); if (line.length > 0) { onLog?.(line, channel); } buffer = buffer.slice(newline + 1); newline = buffer.indexOf("\n"); } }); return () => { if (buffer.length > 0) { onLog?.(buffer, channel); buffer = ""; } }; }