From 035f37d36651de0dd3330e1853c899131fe41b9f Mon Sep 17 00:00:00 2001 From: Luan van der Westhuizen Date: Thu, 4 Jun 2026 11:46:16 +0200 Subject: [PATCH] feat: support prisma next branch database setup --- docs/product/command-spec.md | 8 +- docs/product/error-conventions.md | 2 +- docs/product/resource-model.md | 8 +- .../cli/src/lib/app/branch-database-deploy.ts | 92 ++++- packages/cli/src/lib/app/branch-database.ts | 298 ++++++++++++--- packages/cli/src/presenters/app.ts | 15 +- packages/cli/src/types/app.ts | 3 +- .../cli/tests/app-branch-database.test.ts | 358 ++++++++++++++++++ 8 files changed, 720 insertions(+), 64 deletions(-) diff --git a/docs/product/command-spec.md b/docs/product/command-spec.md index fc1410a..4738d3b 100644 --- a/docs/product/command-spec.md +++ b/docs/product/command-spec.md @@ -629,7 +629,7 @@ Behavior: - deploy progress uses short stage copy (`Building locally...`, `Built `, `Uploading...`, `Uploaded`, `Deploying...`, `Deployed`) and never prints `Status: running` or `Deployment is running at ...` - success human output prints `Live in `, the URL on its own line, and `Logs prisma-cli app logs` - accepts repeated `--env NAME=VALUE` flags -- supports `--db` for preview Branches to create a new empty Prisma Postgres database, apply the local Prisma schema when one exists, and write branch-scoped `DATABASE_URL` and `DIRECT_URL` overrides through the existing `project env` storage +- supports `--db` for preview Branches to create a new empty Prisma Postgres database, apply a supported local Prisma schema source when one exists, and write branch-scoped `DATABASE_URL` and `DIRECT_URL` overrides through the existing `project env` storage - supports `--no-db` to suppress automatic database prompting for the deploy - `--db` and `--no-db` are mutually exclusive; passing both is rejected - `--yes` alone never creates a database; CI must pass `--db --yes` to create and wire one @@ -638,8 +638,10 @@ Behavior: - when only `DIRECT_URL` exists on the branch, explicit `--db` treats it as partial setup and repairs the pair by writing fresh branch database env values - if schema setup or branch env-var wiring fails after database creation, the CLI deletes the newly created database before returning the error - branch database setup does not clone or infer schema from another database; it only creates an empty database and optionally applies schema from local code -- when `prisma/migrations` exists next to `schema.prisma`, schema setup runs `prisma migrate deploy`; otherwise a found `schema.prisma` runs `prisma db push` -- when no `schema.prisma` is found, `--db` still creates the database and env overrides but skips schema setup +- Prisma Next config (`prisma-next.config.*`) is preferred over `schema.prisma`; setup runs `prisma-next contract emit` and then `prisma-next db init` +- for Prisma ORM `schema.prisma`, setup runs `prisma migrate deploy` when `prisma/migrations` exists next to the schema, otherwise it runs `prisma db push` +- when no supported Prisma schema source is found, `--db` still creates the database and env overrides but skips schema setup +- known non-Postgres Prisma sources do not trigger automatic database prompting; explicit `--db` is rejected because the created database is Prisma Postgres - if schema setup fails, deploy stops before the app build/deploy starts - inline `--env DATABASE_URL=...` or `--env DIRECT_URL=...` suppresses automatic database prompting; combining those inline env vars with `--db` is rejected - maps user-facing framework names to deploy build strategies diff --git a/docs/product/error-conventions.md b/docs/product/error-conventions.md index 0d56877..5fee04b 100644 --- a/docs/product/error-conventions.md +++ b/docs/product/error-conventions.md @@ -238,7 +238,7 @@ Recommended meanings: - `REPO_CONNECTION_FAILED`: the Management API repository connection operation failed - `BUILD_FAILED`: build failed before a healthy deployment existed - `BRANCH_DATABASE_SETUP_FAILED`: preview Branch database creation or branch env-var wiring failed before deployment started -- `SCHEMA_SETUP_FAILED`: local Prisma schema setup against a newly created Branch database failed before deployment started +- `SCHEMA_SETUP_FAILED`: local Prisma schema source setup against a newly created Branch database failed before deployment started - `RUN_FAILED`: local framework run command could not be started or exited unsuccessfully - `DEPLOY_FAILED`: deployment or post-build health failed - `VERSION_UNAVAILABLE`: CLI could not read its own bundled package metadata to report a version (defensive; not expected in normal installs) diff --git a/docs/product/resource-model.md b/docs/product/resource-model.md index bb6c67a..b7b0a48 100644 --- a/docs/product/resource-model.md +++ b/docs/product/resource-model.md @@ -154,9 +154,9 @@ resource. The beta package does not expose a standalone database command group yet. The current database surface is limited to `app deploy --db`, which can create an -empty Prisma Postgres database for a preview Branch, apply the local -`schema.prisma` shape when available, and write normal branch-scoped -environment variable overrides. +empty Prisma Postgres database for a preview Branch, apply a supported local +Prisma schema source when available, and write normal branch-scoped environment +variable overrides. Rules: @@ -164,6 +164,8 @@ Rules: - `DATABASE_URL` is written as a preview Branch override, not a separate app binding - branch database setup never overwrites an existing branch-scoped `DATABASE_URL` - schema setup is sourced only from local code; the CLI does not clone or infer schema from another database +- Prisma Next config (`prisma-next.config.*`) is preferred over `schema.prisma` +- known non-Postgres Prisma sources are treated as unsupported for automatic Prisma Postgres setup - production database configuration is managed through explicit environment-variable commands ## Relationships diff --git a/packages/cli/src/lib/app/branch-database-deploy.ts b/packages/cli/src/lib/app/branch-database-deploy.ts index ebf49d1..01114ec 100644 --- a/packages/cli/src/lib/app/branch-database-deploy.ts +++ b/packages/cli/src/lib/app/branch-database-deploy.ts @@ -16,8 +16,10 @@ import { hasBranchDatabaseSignal, inspectBranchDatabaseSignal, runBranchDatabaseSchemaSetup, + type BranchDatabaseSchema, type BranchDatabaseSchemaSetupResult, type BranchDatabaseSignal, + type UnsupportedBranchDatabaseSchema, } from "./branch-database"; export interface BranchDatabaseDeployBranch { @@ -113,6 +115,14 @@ export async function maybeSetupBranchDatabase( }; } + if (localSignal.unsupportedSchema) { + if (options.db === true) { + throw unsupportedBranchDatabaseSchemaError(localSignal.unsupportedSchema, branch.name, context); + } + + return emptyBranchDatabaseSetupOutcome(); + } + const hasSignal = hasBranchDatabaseSignal(localSignal) || Boolean(envState.previewDatabaseUrl); if (options.db !== true) { if (!hasSignal) { @@ -175,11 +185,11 @@ async function setupBranchDatabase( databaseUrl: database.databaseUrl, directUrl: database.directUrl, }).catch((error) => { - throw schemaSetupFailedError(error, signal.schema!, branch.name); + throw schemaSetupFailedError(error, signal.schema!, branch.name, context.runtime.cwd); }); emitBranchDatabaseProgress(context, "success", "Applied database schema"); } else { - skippedSchemaWarning = "No schema.prisma file was found. Branch database env vars were created, but schema setup was skipped."; + skippedSchemaWarning = "No supported Prisma schema source was found. Branch database env vars were created, but schema setup was skipped."; } const envVars = await upsertBranchDatabaseEnvVars(context, provider, projectId, branch, database, envState); @@ -200,6 +210,7 @@ async function setupBranchDatabase( schema: schemaSetup ? { command: schemaSetup.command, + source: schemaSetup.source, path: schemaSetup.schemaPath, } : null, @@ -341,7 +352,7 @@ function maybeRenderBranchDatabaseSignal( const rows = [ signal.schema - ? ` Schema ${path.relative(context.runtime.cwd, signal.schema.path) || "schema.prisma"}` + ? ` Schema ${path.relative(context.runtime.cwd, signal.schema.path) || defaultSchemaSourcePath(signal.schema)}` : null, signal.databaseUrlReferences.length > 0 ? ` Code ${signal.databaseUrlReferences.slice(0, 3).join(", ")}` @@ -388,9 +399,14 @@ function emptyBranchDatabaseSetupOutcome(): BranchDatabaseSetupOutcome { } function formatSchemaSetupCommand(command: BranchDatabaseSchemaSetupResult["command"]): string { - return command === "migrate-deploy" - ? "prisma migrate deploy" - : "prisma db push"; + switch (command) { + case "migrate-deploy": + return "prisma migrate deploy"; + case "db-push": + return "prisma db push"; + case "prisma-next-db-init": + return "prisma-next db init"; + } } function branchDatabaseSetupFailedError(summary: string, error: unknown, branchName: string): CliError { @@ -467,8 +483,9 @@ function branchDatabaseCleanupFailedError( function schemaSetupFailedError( error: unknown, - schema: NonNullable, + schema: BranchDatabaseSchema, branchName: string, + cwd: string, ): CliError { return new CliError({ code: "SCHEMA_SETUP_FAILED", @@ -480,18 +497,73 @@ function schemaSetupFailedError( meta: { branch: branchName, schemaPath: schema.path, + source: schema.kind, command: schema.command, }, exitCode: 1, nextSteps: [ - schema.command === "migrate-deploy" - ? "npx --no-install prisma migrate deploy" - : "npx --no-install prisma db push --skip-generate", + ...formatSchemaSetupNextSteps(schema, cwd), `prisma-cli app deploy --branch ${formatCommandArgument(branchName)} --db`, ], }); } +function unsupportedBranchDatabaseSchemaError( + schema: UnsupportedBranchDatabaseSchema, + branchName: string, + context: CommandContext, +): CliError { + const sourcePath = path.relative(context.runtime.cwd, schema.path) || defaultUnsupportedSchemaSourcePath(schema); + return usageError( + "Branch database setup is not available for this Prisma schema", + `${sourcePath} targets ${formatUnsupportedSchemaTarget(schema.target)}, but --db creates Prisma Postgres databases.`, + "Use project env commands to provide a database URL for this branch, or switch the Prisma schema source to PostgreSQL before using --db.", + [ + `prisma-cli project env add DATABASE_URL= --branch ${formatCommandArgument(branchName)}`, + `prisma-cli app deploy --branch ${formatCommandArgument(branchName)}`, + ], + "app", + ); +} + +function formatSchemaSetupNextSteps(schema: BranchDatabaseSchema, cwd: string): string[] { + const sourcePath = path.relative(cwd, schema.path) || defaultSchemaSourcePath(schema); + switch (schema.command) { + case "migrate-deploy": + return [`npx --no-install prisma migrate deploy --schema ${formatCommandArgument(sourcePath)}`]; + case "db-push": + return [`npx --no-install prisma db push --schema ${formatCommandArgument(sourcePath)}`]; + case "prisma-next-db-init": + return [ + `npx --no-install prisma-next contract emit --config ${formatCommandArgument(sourcePath)}`, + `npx --no-install prisma-next db init --config ${formatCommandArgument(sourcePath)} --db `, + ]; + } +} + +function defaultSchemaSourcePath(schema: BranchDatabaseSchema): string { + return schema.kind === "prisma-next" ? "prisma-next.config.ts" : "schema.prisma"; +} + +function defaultUnsupportedSchemaSourcePath(schema: UnsupportedBranchDatabaseSchema): string { + return schema.kind === "prisma-next" ? "prisma-next.config.ts" : "schema.prisma"; +} + +function formatUnsupportedSchemaTarget(target: UnsupportedBranchDatabaseSchema["target"]): string { + switch (target) { + case "cockroachdb": + return "CockroachDB"; + case "mongodb": + return "MongoDB"; + case "mysql": + return "MySQL"; + case "sqlite": + return "SQLite"; + case "sqlserver": + return "SQL Server"; + } +} + function formatDebugDetails(error: unknown): string | null { if (error instanceof Error) { return error.stack ?? error.message; diff --git a/packages/cli/src/lib/app/branch-database.ts b/packages/cli/src/lib/app/branch-database.ts index ae95daa..011e065 100644 --- a/packages/cli/src/lib/app/branch-database.ts +++ b/packages/cli/src/lib/app/branch-database.ts @@ -5,19 +5,39 @@ import path from "node:path"; import type { CommandContext } from "../../shell/runtime"; -export type BranchDatabaseSchemaCommand = "migrate-deploy" | "db-push"; +export type BranchDatabaseSchemaCommand = "migrate-deploy" | "db-push" | "prisma-next-db-init"; +export type BranchDatabaseSchemaSourceKind = "prisma-orm" | "prisma-next"; + +export type UnsupportedBranchDatabaseSchemaTarget = + | "mongodb" + | "mysql" + | "sqlite" + | "sqlserver" + | "cockroachdb"; + +export interface BranchDatabaseSchema { + kind: BranchDatabaseSchemaSourceKind; + path: string; + command: BranchDatabaseSchemaCommand; + hasMigrations: boolean; + target: "postgresql" | "unknown"; +} + +export interface UnsupportedBranchDatabaseSchema { + kind: BranchDatabaseSchemaSourceKind; + path: string; + target: UnsupportedBranchDatabaseSchemaTarget; +} export interface BranchDatabaseSignal { - schema: { - path: string; - hasMigrations: boolean; - command: BranchDatabaseSchemaCommand; - } | null; + schema: BranchDatabaseSchema | null; + unsupportedSchema: UnsupportedBranchDatabaseSchema | null; databaseUrlReferences: string[]; } export interface BranchDatabaseSchemaSetupResult { command: BranchDatabaseSchemaCommand; + source: BranchDatabaseSchemaSourceKind; schemaPath: string; } @@ -29,6 +49,7 @@ const SKIPPED_DIRECTORIES = new Set([ ".prisma", ".turbo", ".vercel", + ".wrangler", "build", "coverage", "dist", @@ -60,55 +81,75 @@ export async function inspectBranchDatabaseSignal( const state: ScanState = { filesVisited: 0, schemaCandidates: [], + prismaNextConfigCandidates: [], databaseUrlReferences: [], }; await scanDirectory(cwd, cwd, 0, state, signal); - const schemaPath = selectSchemaPath(cwd, state.schemaCandidates); - const hasMigrations = schemaPath - ? await hasMigrationsDirectory(path.dirname(schemaPath), signal) - : false; - const schema = schemaPath + const prismaNextConfigs = await Promise.all( + state.prismaNextConfigCandidates.map((configPath) => classifyPrismaNextConfig(configPath, signal)), + ); + const supportedPrismaNextConfig = selectPrismaNextConfig(cwd, prismaNextConfigs, "supported"); + const unsupportedPrismaNextConfig = selectPrismaNextConfig(cwd, prismaNextConfigs, "unsupported"); + const selectedPrismaOrmSchema = await selectPrismaOrmSchema(cwd, state.schemaCandidates, signal); + + const schema = supportedPrismaNextConfig ? { - path: schemaPath, - hasMigrations, - command: hasMigrations - ? "migrate-deploy" as const - : "db-push" as const, + kind: "prisma-next" as const, + path: supportedPrismaNextConfig.path, + hasMigrations: false, + command: "prisma-next-db-init" as const, + target: supportedPrismaNextConfig.target, } - : null; + : selectedPrismaOrmSchema.schema; + const unsupportedSchema = schema + ? null + : unsupportedPrismaNextConfig + ? { + kind: "prisma-next" as const, + path: unsupportedPrismaNextConfig.path, + target: unsupportedPrismaNextConfig.target, + } + : selectedPrismaOrmSchema.unsupportedSchema; return { schema, + unsupportedSchema, databaseUrlReferences: state.databaseUrlReferences, }; } export function hasBranchDatabaseSignal(signal: BranchDatabaseSignal): boolean { + if (signal.unsupportedSchema) { + return false; + } return Boolean(signal.schema || signal.databaseUrlReferences.length > 0); } export async function runBranchDatabaseSchemaSetup(options: { context: CommandContext; - schema: NonNullable; + schema: BranchDatabaseSchema; databaseUrl: string; directUrl: string | null; }): Promise { - const schemaPath = path.relative(options.context.runtime.cwd, options.schema.path) || "schema.prisma"; - const args = buildPrismaSchemaCommandArgs(options.schema.command, schemaPath); - - await runPrismaCommand({ - context: options.context, - args, - env: { - DATABASE_URL: options.databaseUrl, - ...(options.directUrl ? { DIRECT_URL: options.directUrl } : {}), - }, - }); + const schemaPath = path.relative(options.context.runtime.cwd, options.schema.path) || defaultSchemaSourcePath(options.schema); + const commands = buildSchemaSetupCommands(options.schema, schemaPath, options.databaseUrl); + + for (const command of commands) { + await runPrismaCommand({ + context: options.context, + ...command, + env: { + DATABASE_URL: options.databaseUrl, + ...(options.directUrl ? { DIRECT_URL: options.directUrl } : {}), + }, + }); + } return { command: options.schema.command, + source: options.schema.kind, schemaPath, }; } @@ -116,9 +157,20 @@ export async function runBranchDatabaseSchemaSetup(options: { interface ScanState { filesVisited: number; schemaCandidates: string[]; + prismaNextConfigCandidates: string[]; databaseUrlReferences: string[]; } +interface ClassifiedPrismaNextConfig { + path: string; + target: "postgresql" | "unknown" | UnsupportedBranchDatabaseSchemaTarget; +} + +interface PrismaOrmSchemaSelection { + schema: BranchDatabaseSchema | null; + unsupportedSchema: UnsupportedBranchDatabaseSchema | null; +} + async function scanDirectory( cwd: string, directory: string, @@ -167,6 +219,10 @@ async function scanDirectory( state.schemaCandidates.push(entryPath); } + if (isPrismaNextConfigFile(entry.name)) { + state.prismaNextConfigCandidates.push(entryPath); + } + if ( state.databaseUrlReferences.length < MAX_DATABASE_URL_REFERENCE_FILES && shouldScanForDatabaseUrl(entry.name) @@ -177,18 +233,73 @@ async function scanDirectory( } } -function selectSchemaPath(cwd: string, candidates: string[]): string | null { +async function selectPrismaOrmSchema( + cwd: string, + candidates: string[], + signal: AbortSignal, +): Promise { + const sorted = sortByPreferredRelativePath(cwd, 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: { + kind: "prisma-orm", + path: schemaPath, + hasMigrations, + command: hasMigrations ? "migrate-deploy" : "db-push", + target, + }, + unsupportedSchema: null, + }; + } + + return { + schema: null, + unsupportedSchema: { + kind: "prisma-orm", + path: schemaPath, + target, + }, + }; + } + + return { + schema: null, + unsupportedSchema: null, + }; +} + +function selectPrismaNextConfig( + cwd: 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(cwd, 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(cwd: string, candidates: string[], preferredRootFile: string): string[] { return candidates .map((candidate) => ({ absolute: candidate, - relative: path.relative(cwd, candidate) || "schema.prisma", + relative: path.relative(cwd, candidate) || preferredRootFile, })) .sort((left, right) => { - if (left.relative === "schema.prisma") return -1; - if (right.relative === "schema.prisma") return 1; + if (left.relative === preferredRootFile) return -1; + if (right.relative === preferredRootFile) return 1; return left.relative.length - right.relative.length || left.relative.localeCompare(right.relative); - })[0]?.absolute ?? null; + }) + .map((candidate) => candidate.absolute); } async function hasMigrationsDirectory(schemaDirectory: string, signal: AbortSignal): Promise { @@ -207,6 +318,68 @@ async function hasMigrationsDirectory(schemaDirectory: string, signal: AbortSign } } +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" | UnsupportedBranchDatabaseSchemaTarget> { + 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 shouldScanForDatabaseUrl(fileName: string): boolean { if (fileName === ".env" || fileName.startsWith(".env.")) { return true; @@ -214,29 +387,68 @@ function shouldScanForDatabaseUrl(fileName: string): boolean { return DATABASE_URL_SCAN_EXTENSIONS.has(path.extname(fileName)); } +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 fileContainsDatabaseUrl(filePath: string, signal: AbortSignal): Promise { + const content = await readTextFileIfSmall(filePath, signal); + return content?.includes("DATABASE_URL") ?? false; +} + +async function readTextFileIfSmall(filePath: string, signal: AbortSignal): Promise { signal.throwIfAborted(); const info = await stat(filePath); if (info.size > MAX_TEXT_FILE_BYTES) { - return false; + return null; } - const content = await readFile(filePath, { encoding: "utf8", signal }); - return content.includes("DATABASE_URL"); + return readFile(filePath, { encoding: "utf8", signal }); } -function buildPrismaSchemaCommandArgs(command: BranchDatabaseSchemaCommand, schemaPath: string): string[] { - if (command === "migrate-deploy") { - return ["--no-install", "prisma", "migrate", "deploy", "--schema", schemaPath]; +function buildSchemaSetupCommands(schema: BranchDatabaseSchema, schemaPath: string, databaseUrl: string): Array<{ + args: string[]; + displayCommand: string; +}> { + if (schema.command === "migrate-deploy") { + return [{ + args: ["--no-install", "prisma", "migrate", "deploy", "--schema", schemaPath], + displayCommand: "npx --no-install prisma migrate deploy", + }]; + } + + if (schema.command === "db-push") { + return [{ + args: ["--no-install", "prisma", "db", "push", "--schema", schemaPath], + displayCommand: "npx --no-install prisma db push", + }]; } - return ["--no-install", "prisma", "db", "push", "--skip-generate", "--schema", schemaPath]; + return [ + { + args: ["--no-install", "prisma-next", "contract", "emit", "--config", schemaPath], + displayCommand: "npx --no-install prisma-next contract emit", + }, + { + args: ["--no-install", "prisma-next", "db", "init", "--config", schemaPath, "--db", databaseUrl], + displayCommand: "npx --no-install prisma-next db init", + }, + ]; +} + +function defaultSchemaSourcePath(schema: BranchDatabaseSchema): string { + return schema.kind === "prisma-next" ? "prisma-next.config.ts" : "schema.prisma"; } async function runPrismaCommand(options: { context: CommandContext; args: string[]; + displayCommand: string; env: Record; }): Promise { const shouldPipeOutput = !options.context.flags.json && !options.context.flags.quiet; @@ -261,10 +473,10 @@ async function runPrismaCommand(options: { }); if (exit.signal) { - throw new Error(`npx prisma was terminated by ${exit.signal}.`); + throw new Error(`${options.displayCommand} was terminated by ${exit.signal}.`); } if (exit.code !== 0) { - throw new Error(`npx prisma exited with code ${exit.code ?? 1}.`); + throw new Error(`${options.displayCommand} exited with code ${exit.code ?? 1}.`); } } diff --git a/packages/cli/src/presenters/app.ts b/packages/cli/src/presenters/app.ts index a55cfb0..23a2beb 100644 --- a/packages/cli/src/presenters/app.ts +++ b/packages/cli/src/presenters/app.ts @@ -87,15 +87,24 @@ function renderBranchDatabaseDeploySummary( ...(result.branchDatabase.schema ? [{ label: "Schema", - value: result.branchDatabase.schema.command === "migrate-deploy" - ? "prisma migrate deploy" - : "prisma db push", + value: formatBranchDatabaseSchemaCommand(result.branchDatabase.schema.command), }] : []), ]), ]; } +function formatBranchDatabaseSchemaCommand(command: "migrate-deploy" | "db-push" | "prisma-next-db-init"): string { + switch (command) { + case "migrate-deploy": + return "prisma migrate deploy"; + case "db-push": + return "prisma db push"; + case "prisma-next-db-init": + return "prisma-next db init"; + } +} + function formatDuration(durationMs: number): string { if (durationMs < 1000) { return `${durationMs}ms`; diff --git a/packages/cli/src/types/app.ts b/packages/cli/src/types/app.ts index 7038437..5ce2942 100644 --- a/packages/cli/src/types/app.ts +++ b/packages/cli/src/types/app.ts @@ -32,7 +32,8 @@ export interface AppDeployResult { }; envVars: string[]; schema: { - command: "migrate-deploy" | "db-push"; + command: "migrate-deploy" | "db-push" | "prisma-next-db-init"; + source: "prisma-orm" | "prisma-next"; path: string; } | null; }; diff --git a/packages/cli/tests/app-branch-database.test.ts b/packages/cli/tests/app-branch-database.test.ts index 3a63598..4a42d37 100644 --- a/packages/cli/tests/app-branch-database.test.ts +++ b/packages/cli/tests/app-branch-database.test.ts @@ -1,3 +1,4 @@ +import { EventEmitter } from "node:events"; import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; @@ -38,11 +39,117 @@ afterEach(() => { vi.doUnmock("../src/lib/app/branch-database"); vi.doUnmock("../src/lib/app/branch-database-deploy"); vi.doUnmock("../src/shell/prompt"); + vi.doUnmock("node:child_process"); vi.resetModules(); vi.restoreAllMocks(); }); describe("app deploy branch database setup", () => { + it("runs the expected schema setup commands for Prisma Next and Prisma ORM", async () => { + const spawn = vi.fn().mockImplementation(() => { + const child = new EventEmitter(); + queueMicrotask(() => child.emit("close", 0, null)); + return child; + }); + vi.doMock("node:child_process", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + spawn, + }; + }); + + const { createTempCwd, createTestCommandContext } = await import("./helpers"); + const { runBranchDatabaseSchemaSetup } = await import("../src/lib/app/branch-database"); + const cwd = await createTempCwd(); + await mkdir(path.join(cwd, "prisma"), { recursive: true }); + await writeFile(path.join(cwd, "prisma/schema.prisma"), "datasource db { provider = \"postgresql\" url = env(\"DATABASE_URL\") }\n"); + await writeFile(path.join(cwd, "prisma-next.config.ts"), [ + 'import { defineConfig } from "@prisma-next/postgres/config";', + "", + "export default defineConfig({ contract: './src/prisma/contract.prisma' });", + "", + ].join("\n")); + const { context } = await createTestCommandContext({ + cwd, + stateDir: path.join(cwd, ".state"), + flags: { + quiet: true, + }, + env: { + ...process.env, + PRISMA_CLI_MOCK_FIXTURE_PATH: undefined, + }, + }); + + await runBranchDatabaseSchemaSetup({ + context, + schema: { + kind: "prisma-next", + path: path.join(cwd, "prisma-next.config.ts"), + command: "prisma-next-db-init", + hasMigrations: false, + target: "postgresql", + }, + databaseUrl: "postgres://pooled", + directUrl: "postgres://direct", + }); + + expect(spawn).toHaveBeenNthCalledWith( + 1, + "npx", + ["--no-install", "prisma-next", "contract", "emit", "--config", "prisma-next.config.ts"], + expect.objectContaining({ + cwd, + env: expect.objectContaining({ + DATABASE_URL: "postgres://pooled", + DIRECT_URL: "postgres://direct", + }), + stdio: ["ignore", "ignore", "ignore"], + }), + ); + expect(spawn).toHaveBeenNthCalledWith( + 2, + "npx", + ["--no-install", "prisma-next", "db", "init", "--config", "prisma-next.config.ts", "--db", "postgres://pooled"], + expect.objectContaining({ + cwd, + env: expect.objectContaining({ + DATABASE_URL: "postgres://pooled", + DIRECT_URL: "postgres://direct", + }), + stdio: ["ignore", "ignore", "ignore"], + }), + ); + + spawn.mockClear(); + await runBranchDatabaseSchemaSetup({ + context, + schema: { + kind: "prisma-orm", + path: path.join(cwd, "prisma/schema.prisma"), + command: "db-push", + hasMigrations: false, + target: "postgresql", + }, + databaseUrl: "postgres://pooled", + directUrl: null, + }); + + expect(spawn).toHaveBeenCalledWith( + "npx", + ["--no-install", "prisma", "db", "push", "--schema", "prisma/schema.prisma"], + expect.objectContaining({ + cwd, + env: expect.objectContaining({ + DATABASE_URL: "postgres://pooled", + }), + stdio: ["ignore", "ignore", "ignore"], + }), + ); + expect(spawn.mock.calls[0]?.[1]).not.toContain("--skip-generate"); + }); + it("deploy --db creates a branch database, applies schema, and writes branch env overrides before deploying", async () => { const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); const branchId = "branch_feature_db"; @@ -82,6 +189,7 @@ describe("app deploy branch database setup", () => { }); const runBranchDatabaseSchemaSetup = vi.fn().mockResolvedValue({ command: "db-push", + source: "prisma-orm", schemaPath: "prisma/schema.prisma", }); @@ -186,11 +294,146 @@ describe("app deploy branch database setup", () => { envVars: ["DATABASE_URL", "DIRECT_URL"], schema: { command: "db-push", + source: "prisma-orm", path: "prisma/schema.prisma", }, }); }); + it("deploy --db creates a branch database and applies a Prisma Next config before deploying", async () => { + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const branchId = "branch_feature_next"; + const listApps = vi.fn().mockResolvedValue([ + { id: "app_1", name: "hello-world", region: "eu-central-1", liveDeploymentId: null, liveUrl: null }, + ]); + const createBranchDatabase = vi.fn().mockResolvedValue({ + id: "db_1", + name: "feature/next", + branchId, + databaseUrl: "postgres://pooled", + directUrl: "postgres://direct", + }); + const createEnvironmentVariable = vi.fn().mockImplementation(async (options: { key: string; branchId?: string; className: string }) => ({ + id: `env_${options.key.toLowerCase()}`, + key: options.key, + branchId: options.branchId ?? null, + className: options.className, + isManagedBySystem: false, + })); + const runBranchDatabaseSchemaSetup = vi.fn().mockResolvedValue({ + command: "prisma-next-db-init", + source: "prisma-next", + schemaPath: "prisma-next.config.ts", + }); + const deployApp = vi.fn().mockResolvedValue({ + projectId: "proj_123", + app: { + id: "app_1", + name: "hello-world", + region: "eu-central-1", + liveDeploymentId: "dep_123", + liveUrl: "https://hello-world.prisma.app", + }, + deployment: { + id: "dep_123", + status: "running", + url: "https://hello-world.prisma.app", + }, + }); + + vi.doMock("../src/lib/auth/guard", () => ({ + requireComputeAuth, + })); + vi.doMock("../src/lib/app/branch-database", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + runBranchDatabaseSchemaSetup, + }; + }); + vi.doMock("../src/lib/app/preview-provider", () => ({ + createPreviewAppProvider: vi.fn(() => ({ + resolveBranch: vi.fn().mockResolvedValue({ + id: branchId, + name: "feature/next", + role: "preview", + }), + listApps, + createBranchDatabase, + listEnvironmentVariables: vi.fn().mockResolvedValue([]), + createEnvironmentVariable, + updateEnvironmentVariable: vi.fn(), + deployApp, + listDeployments: vi.fn(), + showDeployment: vi.fn(), + })), + })); + + const { createTempCwd, createTestCommandContext } = await import("./helpers"); + const { runAppDeploy } = await import("../src/controllers/app"); + const cwd = await createTempCwd(); + await writeFile(path.join(cwd, "prisma-next.config.ts"), [ + 'import { defineConfig } from "@prisma-next/postgres/config";', + "", + "export default defineConfig({", + " db: { connection: process.env.DATABASE_URL! },", + "});", + "", + ].join("\n")); + const { context } = await createTestCommandContext({ + cwd, + stateDir: path.join(cwd, ".state"), + flags: { + yes: true, + }, + env: { + ...process.env, + PRISMA_CLI_MOCK_FIXTURE_PATH: undefined, + }, + }); + + const result = await runAppDeploy(context, "hello-world", { + projectRef: "proj_123", + branchName: "feature/next", + framework: "hono", + db: true, + }); + + expect(runBranchDatabaseSchemaSetup).toHaveBeenCalledWith( + expect.objectContaining({ + databaseUrl: "postgres://pooled", + directUrl: "postgres://direct", + schema: expect.objectContaining({ + kind: "prisma-next", + path: path.join(cwd, "prisma-next.config.ts"), + command: "prisma-next-db-init", + hasMigrations: false, + target: "postgresql", + }), + }), + ); + expect(createEnvironmentVariable).toHaveBeenCalledWith( + expect.objectContaining({ + key: "DATABASE_URL", + value: "postgres://pooled", + }), + ); + expect(runBranchDatabaseSchemaSetup.mock.invocationCallOrder[0]).toBeLessThan(deployApp.mock.invocationCallOrder[0]); + expect(result.result.branchDatabase).toEqual({ + status: "created", + database: { + id: "db_1", + name: "feature/next", + }, + envVars: ["DATABASE_URL", "DIRECT_URL"], + schema: { + command: "prisma-next-db-init", + source: "prisma-next", + path: "prisma-next.config.ts", + }, + }); + }); + it("deploy --db leaves an existing branch DATABASE_URL override unchanged", async () => { const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); const branchId = "branch_feature_db"; @@ -350,6 +593,7 @@ describe("app deploy branch database setup", () => { ...actual, runBranchDatabaseSchemaSetup: vi.fn().mockResolvedValue({ command: "db-push", + source: "prisma-orm", schemaPath: "prisma/schema.prisma", }), }; @@ -472,6 +716,7 @@ describe("app deploy branch database setup", () => { ...actual, runBranchDatabaseSchemaSetup: vi.fn().mockResolvedValue({ command: "db-push", + source: "prisma-orm", schemaPath: "prisma/schema.prisma", }), }; @@ -589,6 +834,7 @@ describe("app deploy branch database setup", () => { ...actual, runBranchDatabaseSchemaSetup: vi.fn().mockResolvedValue({ command: "db-push", + source: "prisma-orm", schemaPath: "prisma/schema.prisma", }), }; @@ -805,6 +1051,7 @@ describe("app deploy branch database setup", () => { ...actual, runBranchDatabaseSchemaSetup: vi.fn().mockResolvedValue({ command: "db-push", + source: "prisma-orm", schemaPath: "prisma/schema.prisma", }), }; @@ -874,4 +1121,115 @@ describe("app deploy branch database setup", () => { expect(signal.schema?.path).toBe(path.join(cwd, "prisma/schema.prisma")); }); + + it("prefers a Prisma Next config over schema.prisma when both exist", async () => { + const { createTempCwd } = await import("./helpers"); + const { inspectBranchDatabaseSignal } = await import("../src/lib/app/branch-database"); + const cwd = await createTempCwd(); + await mkdir(path.join(cwd, "prisma"), { recursive: true }); + await writeFile(path.join(cwd, "prisma/schema.prisma"), "datasource db { provider = \"postgresql\" url = env(\"DATABASE_URL\") }\n"); + await writeFile(path.join(cwd, "prisma-next.config.ts"), [ + 'import { defineConfig } from "@prisma-next/postgres/config";', + "", + "export default defineConfig({", + " db: { connection: process.env.DATABASE_URL! },", + "});", + "", + ].join("\n")); + + const signal = await inspectBranchDatabaseSignal(cwd, new AbortController().signal); + + expect(signal.schema).toMatchObject({ + kind: "prisma-next", + path: path.join(cwd, "prisma-next.config.ts"), + command: "prisma-next-db-init", + target: "postgresql", + }); + expect(signal.unsupportedSchema).toBeNull(); + }); + + it("treats non-Postgres Prisma Next configs as unsupported branch database signals", async () => { + const { createTempCwd } = await import("./helpers"); + const { hasBranchDatabaseSignal, inspectBranchDatabaseSignal } = await import("../src/lib/app/branch-database"); + const cwd = await createTempCwd(); + await writeFile(path.join(cwd, "prisma-next.config.ts"), [ + 'import { defineConfig } from "@prisma-next/mongo/config";', + "", + "export default defineConfig({", + " db: { connection: process.env.DATABASE_URL! },", + "});", + "", + ].join("\n")); + + const signal = await inspectBranchDatabaseSignal(cwd, new AbortController().signal); + + expect(signal.schema).toBeNull(); + expect(signal.unsupportedSchema).toMatchObject({ + kind: "prisma-next", + path: path.join(cwd, "prisma-next.config.ts"), + target: "mongodb", + }); + expect(hasBranchDatabaseSignal(signal)).toBe(false); + }); + + it("rejects --db for non-Postgres Prisma Next configs before creating a branch database", async () => { + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const createBranchDatabase = vi.fn(); + const deployApp = vi.fn(); + + vi.doMock("../src/lib/auth/guard", () => ({ + requireComputeAuth, + })); + vi.doMock("../src/lib/app/preview-provider", () => ({ + createPreviewAppProvider: vi.fn(() => ({ + resolveBranch: createResolveBranch(), + listApps: vi.fn().mockResolvedValue([ + { id: "app_1", name: "hello-world", region: "eu-central-1", liveDeploymentId: null, liveUrl: null }, + ]), + createBranchDatabase, + listEnvironmentVariables: vi.fn().mockResolvedValue([]), + createEnvironmentVariable: vi.fn(), + updateEnvironmentVariable: vi.fn(), + deployApp, + listDeployments: vi.fn(), + showDeployment: vi.fn(), + })), + })); + + const { createTempCwd, createTestCommandContext } = await import("./helpers"); + const { runAppDeploy } = await import("../src/controllers/app"); + const cwd = await createTempCwd(); + await writeFile(path.join(cwd, "prisma-next.config.ts"), [ + 'import { defineConfig } from "@prisma-next/sqlite/config";', + "", + "export default defineConfig({", + " db: { connection: process.env.DATABASE_URL! },", + "});", + "", + ].join("\n")); + const { context } = await createTestCommandContext({ + cwd, + stateDir: path.join(cwd, ".state"), + flags: { + yes: true, + }, + env: { + ...process.env, + PRISMA_CLI_MOCK_FIXTURE_PATH: undefined, + }, + }); + + await expect(runAppDeploy(context, "hello-world", { + projectRef: "proj_123", + branchName: "feature/db", + framework: "hono", + db: true, + })).rejects.toMatchObject({ + code: "USAGE_ERROR", + domain: "app", + summary: "Branch database setup is not available for this Prisma schema", + }); + expect(createBranchDatabase).not.toHaveBeenCalled(); + expect(deployApp).not.toHaveBeenCalled(); + }); });