From e8cb6f93498dc49211c745794f79eb99ba3c8f1e Mon Sep 17 00:00:00 2001 From: Luan van der Westhuizen Date: Thu, 4 Jun 2026 06:21:24 +0200 Subject: [PATCH 1/4] feat: wire branch databases during app deploy --- docs/product/command-spec.md | 15 +- docs/product/error-conventions.md | 4 + docs/product/resource-model.md | 20 +- packages/cli/src/commands/app/index.ts | 4 + packages/cli/src/controllers/app.ts | 22 +- .../cli/src/lib/app/branch-database-deploy.ts | 401 ++++++++++++++ packages/cli/src/lib/app/branch-database.ts | 252 +++++++++ .../src/lib/app/preview-branch-database.ts | 189 +++++++ packages/cli/src/lib/app/preview-provider.ts | 42 ++ packages/cli/src/presenters/app.ts | 29 + packages/cli/src/shell/command-meta.ts | 2 + packages/cli/src/types/app.ts | 13 + .../cli/tests/app-branch-database.test.ts | 504 ++++++++++++++++++ packages/cli/tests/app-controller.test.ts | 148 ++--- packages/cli/tests/app.test.ts | 4 + 15 files changed, 1570 insertions(+), 79 deletions(-) create mode 100644 packages/cli/src/lib/app/branch-database-deploy.ts create mode 100644 packages/cli/src/lib/app/branch-database.ts create mode 100644 packages/cli/src/lib/app/preview-branch-database.ts create mode 100644 packages/cli/tests/app-branch-database.test.ts diff --git a/docs/product/command-spec.md b/docs/product/command-spec.md index 6a1a07b..0d18178 100644 --- a/docs/product/command-spec.md +++ b/docs/product/command-spec.md @@ -590,7 +590,7 @@ prisma-cli app run --build-type nextjs prisma-cli app run --build-type bun --entry server.ts --port 3000 ``` -## `prisma-cli app deploy --project --create-project --app --branch --framework --entry --http-port --env --prod` +## `prisma-cli app deploy --project --create-project --app --branch --framework --entry --http-port --env --db --no-db --prod` Purpose: @@ -629,6 +629,16 @@ 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 `--no-db` to suppress automatic database prompting for the deploy +- `--yes` alone never creates a database; CI must pass `--db --yes` to create and wire one +- branch database setup only runs for preview Branches; production database env vars are managed with `project env` +- branch database setup never overwrites an existing branch-scoped `DATABASE_URL`; when the branch already has one, `--db` leaves it unchanged and continues +- 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 +- 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 - uses `src/index.ts` as the Hono deploy entrypoint when the app has no `package.json#main` or `package.json#module` and that file exists - supports vanilla Bun apps with `--framework bun` using `package.json#main` or `package.json#module`, or with `--entry ` @@ -643,6 +653,9 @@ prisma-cli app deploy prisma-cli app deploy --project proj_123 prisma-cli app deploy --create-project my-app --yes prisma-cli app deploy --app my-app --env DATABASE_URL=postgresql://example +prisma-cli app deploy --db +prisma-cli app deploy --db --yes +prisma-cli app deploy --no-db prisma-cli app deploy --framework nextjs --http-port 3000 prisma-cli app deploy --branch feat-login --framework hono --http-port 3000 prisma-cli app deploy --prod --yes diff --git a/docs/product/error-conventions.md b/docs/product/error-conventions.md index a0ffce8..0d56877 100644 --- a/docs/product/error-conventions.md +++ b/docs/product/error-conventions.md @@ -193,6 +193,8 @@ These codes are the minimum stable set for the MVP: - `REPO_ALREADY_CONNECTED` - `REPO_CONNECTION_FAILED` - `BUILD_FAILED` +- `BRANCH_DATABASE_SETUP_FAILED` +- `SCHEMA_SETUP_FAILED` - `RUN_FAILED` - `DEPLOY_FAILED` - `VERSION_UNAVAILABLE` @@ -235,6 +237,8 @@ Recommended meanings: - `REPO_ALREADY_CONNECTED`: a project already has a different GitHub repository connected - `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 - `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 0a3a6de..bb6c67a 100644 --- a/docs/product/resource-model.md +++ b/docs/product/resource-model.md @@ -149,14 +149,22 @@ top-level target-context group is `branch`, not `env`. ### Schema and Database -`schema` and `database` are out of scope for the current beta package, but -they remain part of the long-term hierarchy. +`schema` stays a local code artifact. `database` stays a branch-bound remote +resource. -- `schema` stays a local code artifact -- `database` stays a branch-bound 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. -The beta package must not redefine project or branch in a way that makes -future schema, database, and migration workflows awkward. +Rules: + +- database wiring uses the existing environment-variable model +- `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 +- production database configuration is managed through explicit environment-variable commands ## Relationships diff --git a/packages/cli/src/commands/app/index.ts b/packages/cli/src/commands/app/index.ts index 49b3eed..3f18f8f 100644 --- a/packages/cli/src/commands/app/index.ts +++ b/packages/cli/src/commands/app/index.ts @@ -182,6 +182,8 @@ function createDeployCommand(runtime: CliRuntime): Command { new Option("--env ", "Environment variable") .argParser(collectRepeatableValues), ) + .addOption(new Option("--db", "Create and wire an isolated database for the preview Branch")) + .addOption(new Option("--no-db", "Skip branch database setup")) .addOption(new Option("--prod", "Confirm intent to deploy to production")); addGlobalFlags(command); @@ -195,6 +197,7 @@ function createDeployCommand(runtime: CliRuntime): Command { const projectRef = (options as { project?: string }).project; const createProjectName = (options as { createProject?: string }).createProject; const prod = (options as { prod?: boolean }).prod; + const db = (options as { db?: boolean }).db; await runCommand( runtime, @@ -209,6 +212,7 @@ function createDeployCommand(runtime: CliRuntime): Command { httpPort, envAssignments, prod: prod === true, + db, }), { renderHuman: (context, descriptor, result) => renderAppDeploy(context, descriptor, result), diff --git a/packages/cli/src/controllers/app.ts b/packages/cli/src/controllers/app.ts index a4aa3ab..0f80524 100644 --- a/packages/cli/src/controllers/app.ts +++ b/packages/cli/src/controllers/app.ts @@ -79,6 +79,7 @@ import { type PreviewBuildType, } from "../lib/app/preview-build"; import { PREVIEW_DEFAULT_REGION } from "../lib/app/preview-interaction"; +import { maybeSetupBranchDatabase } from "../lib/app/branch-database-deploy"; import { createPreviewDeployProgress, createPreviewDeployProgressState, @@ -224,6 +225,7 @@ export async function runAppDeploy( httpPort?: string; envAssignments?: string[]; prod?: boolean; + db?: boolean; }, ): Promise> { ensurePreviewAppMode(context); @@ -320,6 +322,10 @@ export async function runAppDeploy( assertSupportedEntrypoint(buildType, options?.entrypoint, "deploy"); const entrypoint = await resolveDeployEntrypoint(context.runtime.cwd, framework, options?.entrypoint, context.runtime.signal); const portMapping = parseDeployPortMapping(String(runtime.port)); + const branchDatabaseSetup = await maybeSetupBranchDatabase(context, provider, projectId, target.branch, { + db: options?.db, + inlineEnvVars: envVars, + }); const progressState = createPreviewDeployProgressState(); const deployStartedAt = Date.now(); @@ -353,8 +359,9 @@ export async function runAppDeploy( result: { workspace: target.workspace, project: target.project, - branch: target.branch, + branch: toResultBranch(target.branch), resolution: target.resolution, + branchDatabase: branchDatabaseSetup.result, app: { id: deployResult.app.id, name: deployResult.app.name, @@ -363,7 +370,7 @@ export async function runAppDeploy( durationMs: deployDurationMs, localPin: localPinResult, }, - warnings: [], + warnings: branchDatabaseSetup.warnings, nextSteps: ["prisma-cli app list-deploys", `prisma-cli app show-deploy ${deployResult.deployment.id}`], }; } @@ -1264,7 +1271,7 @@ async function resolveAppDomainTarget( resultTarget: { workspace: target.workspace, project: target.project, - branch: target.branch, + branch: toResultBranch(target.branch), app: { id: selectedApp.id, name: selectedApp.name, @@ -2199,6 +2206,7 @@ interface ResolvedAppProjectContext { workspace: AuthWorkspace; project: ProjectSummary; branch: { + id: string; name: string; kind: BranchKind; }; @@ -2470,6 +2478,7 @@ async function withRemoteDeployBranch( return { ...target, branch: { + id: remoteBranch.id, name: remoteBranch.name, kind: remoteBranch.role, }, @@ -2480,6 +2489,13 @@ function toBranchKind(name: string): BranchKind { return name === "production" || name === "main" ? "production" : "preview"; } +function toResultBranch(branch: ResolvedAppProjectContext["branch"]): AppDeployResult["branch"] { + return { + name: branch.name, + kind: branch.kind, + }; +} + function assertExclusiveDeployProjectInputs(options: { projectRef: string | undefined; createProjectName: string | undefined; diff --git a/packages/cli/src/lib/app/branch-database-deploy.ts b/packages/cli/src/lib/app/branch-database-deploy.ts new file mode 100644 index 0000000..4ee20f7 --- /dev/null +++ b/packages/cli/src/lib/app/branch-database-deploy.ts @@ -0,0 +1,401 @@ +import path from "node:path"; + +import type { AppDeployResult } from "../../types/app"; +import { CliError, usageError } from "../../shell/errors"; +import { confirmPrompt } from "../../shell/prompt"; +import type { CommandContext } from "../../shell/runtime"; +import { canPrompt } from "../../shell/runtime"; +import { renderSummaryLine } from "../../shell/ui"; +import { formatCommandArgument } from "../project/setup"; +import type { + PreviewAppProvider, + PreviewBranchDatabaseRecord, + PreviewEnvironmentVariableRecord, +} from "./preview-provider"; +import { + hasBranchDatabaseSignal, + inspectBranchDatabaseSignal, + runBranchDatabaseSchemaSetup, + type BranchDatabaseSchemaSetupResult, + type BranchDatabaseSignal, +} from "./branch-database"; + +export interface BranchDatabaseDeployBranch { + id: string; + name: string; + kind: "production" | "preview"; +} + +export interface BranchDatabaseSetupOutcome { + result: AppDeployResult["branchDatabase"] | undefined; + warnings: string[]; +} + +interface BranchDatabaseEnvState { + branchDatabaseUrl: PreviewEnvironmentVariableRecord | null; + branchDirectUrl: PreviewEnvironmentVariableRecord | null; + previewDatabaseUrl: PreviewEnvironmentVariableRecord | null; +} + +export async function maybeSetupBranchDatabase( + context: CommandContext, + provider: PreviewAppProvider, + projectId: string, + branch: BranchDatabaseDeployBranch, + options: { + db: boolean | undefined; + inlineEnvVars: Record | undefined; + }, +): Promise { + if (options.db === false) { + return emptyBranchDatabaseSetupOutcome(); + } + + if (hasInlineDatabaseEnvVars(options.inlineEnvVars)) { + if (options.db === true) { + throw usageError( + "Branch database setup cannot be combined with inline database env vars", + "The deploy command received --db and an inline DATABASE_URL or DIRECT_URL value.", + "Remove the inline --env database value to let --db create a branch override, or remove --db to deploy with the provided value.", + [ + "prisma-cli app deploy --db", + "prisma-cli app deploy --env DATABASE_URL=postgresql://example", + ], + "app", + ); + } + + return emptyBranchDatabaseSetupOutcome(); + } + + if (branch.kind === "production") { + if (options.db === true) { + throw usageError( + "Branch database setup is only available for preview branches", + "Production database wiring is a durable environment decision and is not created implicitly by app deploy.", + "Use project env commands to manage production DATABASE_URL, or deploy a preview branch with --db.", + [ + "prisma-cli project env add DATABASE_URL= --role production", + "prisma-cli app deploy --branch feature/db --db", + ], + "app", + ); + } + + return emptyBranchDatabaseSetupOutcome(); + } + + const localSignal = await inspectBranchDatabaseSignal(context.runtime.cwd, context.runtime.signal); + const envState = await inspectBranchDatabaseEnv(provider, projectId, branch.id, context.runtime.signal); + const branchEnvVars = [envState.branchDatabaseUrl, envState.branchDirectUrl] + .filter((variable): variable is PreviewEnvironmentVariableRecord => Boolean(variable)) + .map((variable) => variable.key) + .sort(); + + if (envState.branchDatabaseUrl) { + const warning = options.db === true + ? `Branch "${branch.name}" already has DATABASE_URL. Leaving branch database env vars unchanged.` + : null; + if (warning) { + emitBranchDatabaseWarning(context, warning); + } + + return { + result: options.db === true + ? { + status: "skipped", + reason: "branch-env-exists", + envVars: branchEnvVars, + schema: null, + } + : undefined, + warnings: warning ? [warning] : [], + }; + } + + const hasSignal = hasBranchDatabaseSignal(localSignal) || Boolean(envState.previewDatabaseUrl); + if (options.db !== true) { + if (!hasSignal) { + return emptyBranchDatabaseSetupOutcome(); + } + + if (!canPrompt(context) || context.flags.yes) { + const warning = "This app appears to use DATABASE_URL. Run prisma-cli app deploy --db to create an isolated database for this preview branch."; + emitBranchDatabaseWarning(context, warning); + return { + result: undefined, + warnings: [warning], + }; + } + + maybeRenderBranchDatabaseSignal(context, branch.name, localSignal, envState); + const shouldCreate = await confirmPrompt({ + input: context.runtime.stdin, + output: context.output.stderr, + message: `Create an isolated database for branch "${branch.name}"?`, + initialValue: false, + }); + + if (!shouldCreate) { + return emptyBranchDatabaseSetupOutcome(); + } + } + + return setupBranchDatabase(context, provider, projectId, branch, localSignal); +} + +async function setupBranchDatabase( + context: CommandContext, + provider: PreviewAppProvider, + projectId: string, + branch: BranchDatabaseDeployBranch, + signal: BranchDatabaseSignal, +): Promise { + emitBranchDatabaseProgress(context, "pending", "Creating branch database"); + const database = await provider.createBranchDatabase({ + projectId, + branchId: branch.id, + branchName: branch.name, + signal: context.runtime.signal, + }).catch((error) => { + throw branchDatabaseSetupFailedError("Failed to create branch database", error, branch.name); + }); + emitBranchDatabaseProgress(context, "success", "Created branch database"); + + let schemaSetup: BranchDatabaseSchemaSetupResult | null = null; + const warnings: string[] = []; + let skippedSchemaWarning: string | null = null; + if (signal.schema) { + emitBranchDatabaseProgress(context, "pending", `Applying database schema with ${formatSchemaSetupCommand(signal.schema.command)}`); + schemaSetup = await runBranchDatabaseSchemaSetup({ + context, + schema: signal.schema, + databaseUrl: database.databaseUrl, + directUrl: database.directUrl, + }).catch((error) => { + throw schemaSetupFailedError(error, signal.schema!, branch.name); + }); + 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."; + } + + const envVars = await createBranchDatabaseEnvVars(context, provider, projectId, branch, database); + emitBranchDatabaseProgress(context, "success", `Added branch env override${envVars.length === 1 ? "" : "s"} ${envVars.join(", ")}`); + if (skippedSchemaWarning) { + emitBranchDatabaseWarning(context, skippedSchemaWarning); + warnings.push(skippedSchemaWarning); + } + + return { + result: { + status: "created", + database: { + id: database.id, + name: database.name, + }, + envVars, + schema: schemaSetup + ? { + command: schemaSetup.command, + path: schemaSetup.schemaPath, + } + : null, + }, + warnings, + }; +} + +async function createBranchDatabaseEnvVars( + context: CommandContext, + provider: PreviewAppProvider, + projectId: string, + branch: BranchDatabaseDeployBranch, + database: PreviewBranchDatabaseRecord, +): Promise { + const created: string[] = []; + await provider.createEnvironmentVariable({ + projectId, + branchId: branch.id, + className: "preview", + key: "DATABASE_URL", + value: database.databaseUrl, + signal: context.runtime.signal, + }).catch((error) => { + throw branchDatabaseSetupFailedError("Failed to write DATABASE_URL", error, branch.name); + }); + created.push("DATABASE_URL"); + + if (database.directUrl) { + await provider.createEnvironmentVariable({ + projectId, + branchId: branch.id, + className: "preview", + key: "DIRECT_URL", + value: database.directUrl, + signal: context.runtime.signal, + }).catch((error) => { + throw branchDatabaseSetupFailedError("Failed to write DIRECT_URL", error, branch.name); + }); + created.push("DIRECT_URL"); + } + + return created; +} + +async function inspectBranchDatabaseEnv( + provider: PreviewAppProvider, + projectId: string, + branchId: string, + signal: AbortSignal, +): Promise { + const [databaseUrlRows, directUrlRows] = await Promise.all([ + provider.listEnvironmentVariables({ + projectId, + className: "preview", + key: "DATABASE_URL", + signal, + }), + provider.listEnvironmentVariables({ + projectId, + className: "preview", + key: "DIRECT_URL", + signal, + }), + ]); + + return { + branchDatabaseUrl: findEnvVar(databaseUrlRows, { branchId }), + branchDirectUrl: findEnvVar(directUrlRows, { branchId }), + previewDatabaseUrl: findEnvVar(databaseUrlRows, { branchId: null }), + }; +} + +function findEnvVar( + rows: PreviewEnvironmentVariableRecord[], + options: { branchId: string | null }, +): PreviewEnvironmentVariableRecord | null { + return rows.find((row) => row.branchId === options.branchId) ?? null; +} + +function hasInlineDatabaseEnvVars(envVars: Record | undefined): boolean { + return Boolean(envVars && ("DATABASE_URL" in envVars || "DIRECT_URL" in envVars)); +} + +function maybeRenderBranchDatabaseSignal( + context: CommandContext, + branchName: string, + signal: BranchDatabaseSignal, + envState: BranchDatabaseEnvState, +): void { + if (context.flags.json || context.flags.quiet) { + return; + } + + const rows = [ + signal.schema + ? ` Schema ${path.relative(context.runtime.cwd, signal.schema.path) || "schema.prisma"}` + : null, + signal.databaseUrlReferences.length > 0 + ? ` Code ${signal.databaseUrlReferences.slice(0, 3).join(", ")}` + : null, + envState.previewDatabaseUrl + ? " Env preview DATABASE_URL is inherited by this branch" + : null, + ].filter((row): row is string => Boolean(row)); + + context.output.stderr.write( + `Database signal found for branch "${branchName}"\n` + + `${rows.join("\n")}\n\n`, + ); +} + +function emitBranchDatabaseProgress( + context: CommandContext, + status: "pending" | "success", + message: string, +): void { + if (context.flags.json || context.flags.quiet) { + return; + } + + const line = status === "pending" + ? `${context.ui.warning("◇")} ${message}...` + : renderSummaryLine(context.ui, "success", message); + context.output.stderr.write(`${line}\n`); +} + +function emitBranchDatabaseWarning(context: CommandContext, warning: string): void { + if (context.flags.json || context.flags.quiet) { + return; + } + + context.output.stderr.write(`${renderSummaryLine(context.ui, "warning", warning)}\n`); +} + +function emptyBranchDatabaseSetupOutcome(): BranchDatabaseSetupOutcome { + return { + result: undefined, + warnings: [], + }; +} + +function formatSchemaSetupCommand(command: BranchDatabaseSchemaSetupResult["command"]): string { + return command === "migrate-deploy" + ? "prisma migrate deploy" + : "prisma db push"; +} + +function branchDatabaseSetupFailedError(summary: string, error: unknown, branchName: string): CliError { + return new CliError({ + code: "BRANCH_DATABASE_SETUP_FAILED", + domain: "app", + summary, + why: error instanceof Error ? error.message : String(error), + fix: "Retry the command, or create the branch database and env vars manually with project env commands.", + debug: formatDebugDetails(error), + meta: { + branch: branchName, + }, + exitCode: 1, + nextSteps: [ + `prisma-cli app deploy --branch ${formatCommandArgument(branchName)} --db`, + `prisma-cli project env list --branch ${formatCommandArgument(branchName)}`, + ], + }); +} + +function schemaSetupFailedError( + error: unknown, + schema: NonNullable, + branchName: string, +): CliError { + return new CliError({ + code: "SCHEMA_SETUP_FAILED", + domain: "app", + summary: "Database schema setup failed", + why: error instanceof Error ? error.message : String(error), + fix: "Fix the Prisma schema or migrations, then rerun deploy with --db.", + debug: formatDebugDetails(error), + meta: { + branch: branchName, + schemaPath: schema.path, + command: schema.command, + }, + exitCode: 1, + nextSteps: [ + schema.command === "migrate-deploy" + ? "npx --no-install prisma migrate deploy" + : "npx --no-install prisma db push --skip-generate", + `prisma-cli app deploy --branch ${formatCommandArgument(branchName)} --db`, + ], + }); +} + +function formatDebugDetails(error: unknown): string | null { + if (error instanceof Error) { + return error.stack ?? error.message; + } + + return typeof error === "string" ? error : null; +} diff --git a/packages/cli/src/lib/app/branch-database.ts b/packages/cli/src/lib/app/branch-database.ts new file mode 100644 index 0000000..8457530 --- /dev/null +++ b/packages/cli/src/lib/app/branch-database.ts @@ -0,0 +1,252 @@ +import { spawn } from "node:child_process"; +import { access, readdir, readFile, stat } from "node:fs/promises"; +import path from "node:path"; + +import type { CommandContext } from "../../shell/runtime"; + +export type BranchDatabaseSchemaCommand = "migrate-deploy" | "db-push"; + +export interface BranchDatabaseSignal { + schema: { + path: string; + hasMigrations: boolean; + command: BranchDatabaseSchemaCommand; + } | null; + databaseUrlReferences: string[]; +} + +export interface BranchDatabaseSchemaSetupResult { + command: BranchDatabaseSchemaCommand; + schemaPath: string; +} + +const SKIPPED_DIRECTORIES = new Set([ + ".git", + ".next", + ".nuxt", + ".output", + ".prisma", + ".turbo", + ".vercel", + "build", + "coverage", + "dist", + "node_modules", + "out", +]); +const DATABASE_URL_SCAN_EXTENSIONS = new Set([ + ".cjs", + ".cts", + ".env", + ".js", + ".json", + ".jsx", + ".mjs", + ".mts", + ".prisma", + ".ts", + ".tsx", +]); +const MAX_SCAN_DEPTH = 6; +const MAX_SCAN_FILES = 1_000; +const MAX_DATABASE_URL_REFERENCE_FILES = 10; +const MAX_TEXT_FILE_BYTES = 1024 * 1024; + +export async function inspectBranchDatabaseSignal( + cwd: string, + signal: AbortSignal, +): Promise { + const state: ScanState = { + filesVisited: 0, + schemaPath: null, + databaseUrlReferences: [], + }; + + await scanDirectory(cwd, cwd, 0, state, signal); + + const hasMigrations = state.schemaPath + ? await hasMigrationsDirectory(path.dirname(state.schemaPath), signal) + : false; + const schema = state.schemaPath + ? { + path: state.schemaPath, + hasMigrations, + command: hasMigrations + ? "migrate-deploy" as const + : "db-push" as const, + } + : null; + + return { + schema, + databaseUrlReferences: state.databaseUrlReferences, + }; +} + +export function hasBranchDatabaseSignal(signal: BranchDatabaseSignal): boolean { + return Boolean(signal.schema || signal.databaseUrlReferences.length > 0); +} + +export async function runBranchDatabaseSchemaSetup(options: { + context: CommandContext; + schema: NonNullable; + 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 } : {}), + }, + }); + + return { + command: options.schema.command, + schemaPath, + }; +} + +interface ScanState { + filesVisited: number; + schemaPath: string | null; + databaseUrlReferences: string[]; +} + +async function scanDirectory( + cwd: string, + directory: string, + depth: number, + state: ScanState, + signal: AbortSignal, +): Promise { + signal.throwIfAborted(); + + if (depth > MAX_SCAN_DEPTH || state.filesVisited >= MAX_SCAN_FILES) { + return; + } + + let entries: Awaited>; + try { + entries = await readdir(directory, { withFileTypes: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return; + } + throw error; + } + + 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(cwd, entryPath, depth + 1, state, signal); + } + continue; + } + + if (!entry.isFile()) { + continue; + } + + state.filesVisited += 1; + + if (!state.schemaPath && entry.name === "schema.prisma") { + state.schemaPath = entryPath; + } + + if ( + state.databaseUrlReferences.length < MAX_DATABASE_URL_REFERENCE_FILES + && shouldScanForDatabaseUrl(entry.name) + && await fileContainsDatabaseUrl(entryPath, signal) + ) { + state.databaseUrlReferences.push(path.relative(cwd, entryPath) || entry.name); + } + } +} + +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; + } +} + +function shouldScanForDatabaseUrl(fileName: string): boolean { + if (fileName === ".env" || fileName.startsWith(".env.")) { + return true; + } + return DATABASE_URL_SCAN_EXTENSIONS.has(path.extname(fileName)); +} + +async function fileContainsDatabaseUrl(filePath: string, signal: AbortSignal): Promise { + signal.throwIfAborted(); + + const info = await stat(filePath); + if (info.size > MAX_TEXT_FILE_BYTES) { + return false; + } + + const content = await readFile(filePath, { encoding: "utf8", signal }); + return content.includes("DATABASE_URL"); +} + +function buildPrismaSchemaCommandArgs(command: BranchDatabaseSchemaCommand, schemaPath: string): string[] { + if (command === "migrate-deploy") { + return ["--no-install", "prisma", "migrate", "deploy", "--schema", schemaPath]; + } + + return ["--no-install", "prisma", "db", "push", "--skip-generate", "--schema", schemaPath]; +} + +async function runPrismaCommand(options: { + context: CommandContext; + args: string[]; + env: Record; +}): Promise { + const child = spawn("npx", options.args, { + cwd: options.context.runtime.cwd, + env: { + ...options.context.runtime.env, + ...options.env, + }, + signal: options.context.runtime.signal, + stdio: ["ignore", "pipe", "pipe"], + }); + + if (!options.context.flags.json && !options.context.flags.quiet) { + child.stdout?.pipe(options.context.output.stderr, { end: false }); + child.stderr?.pipe(options.context.output.stderr, { end: false }); + } + + const exit = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => { + child.once("error", reject); + child.once("close", (code, signal) => resolve({ code, signal })); + }); + + if (exit.signal) { + throw new Error(`npx prisma was terminated by ${exit.signal}.`); + } + + if (exit.code !== 0) { + throw new Error(`npx prisma exited with code ${exit.code ?? 1}.`); + } +} diff --git a/packages/cli/src/lib/app/preview-branch-database.ts b/packages/cli/src/lib/app/preview-branch-database.ts new file mode 100644 index 0000000..79fd20b --- /dev/null +++ b/packages/cli/src/lib/app/preview-branch-database.ts @@ -0,0 +1,189 @@ +import type { ManagementApiClient } from "@prisma/management-api-sdk"; + +export interface PreviewEnvironmentVariableRecord { + id: string; + key: string; + branchId: string | null; + className: "production" | "preview"; + isManagedBySystem: boolean; +} + +export interface PreviewBranchDatabaseRecord { + id: string; + name: string; + branchId: string | null; + databaseUrl: string; + directUrl: string | null; +} + +interface RawEnvironmentVariableRecord { + id: string; + key: string; + branchId: string | null; + class: "production" | "preview"; + isManagedBySystem: boolean; +} + +interface RawDatabaseConnectionEndpoint { + connectionString?: string; +} + +interface RawDatabaseRecord { + id: string; + name: string; + branchId: string | null; + connections?: Array<{ + endpoints?: { + direct?: RawDatabaseConnectionEndpoint; + pooled?: RawDatabaseConnectionEndpoint; + accelerate?: RawDatabaseConnectionEndpoint; + }; + }>; +} + +interface RawApiErrorBody { + error?: { + code?: string; + message?: string; + hint?: string; + }; +} + +export async function createBranchDatabase( + client: ManagementApiClient, + options: { + projectId: string; + branchId: string; + branchName: string; + signal?: AbortSignal; + }, +): Promise { + const result = await client.POST("/v1/databases", { + body: { + projectId: options.projectId, + branchId: options.branchId, + name: options.branchName, + source: { type: "empty" }, + } as never, + signal: options.signal, + }); + + if (result.error || !result.data) { + throw apiCallError(`Failed to create database for branch "${options.branchName}"`, result.response, result.error); + } + + return normalizeBranchDatabaseRecord(result.data.data as RawDatabaseRecord); +} + +export async function listEnvironmentVariables( + client: ManagementApiClient, + options: { + projectId: string; + className?: "production" | "preview"; + key?: string; + branchId?: string; + signal?: AbortSignal; + }, +): Promise { + const variables: RawEnvironmentVariableRecord[] = []; + let cursor: string | undefined; + + // eslint-disable-next-line no-constant-condition + while (true) { + const result = await client.GET("/v1/environment-variables", { + params: { + query: { + projectId: options.projectId, + class: options.className, + key: options.key, + branchId: options.branchId, + cursor, + }, + }, + signal: options.signal, + }); + if (result.error || !result.data) { + throw apiCallError("Failed to list environment variables", result.response, result.error); + } + + variables.push(...result.data.data as RawEnvironmentVariableRecord[]); + + if (!result.data.pagination.hasMore || !result.data.pagination.nextCursor) { + break; + } + cursor = result.data.pagination.nextCursor; + } + + return variables.map((variable) => normalizeEnvironmentVariable(variable)); +} + +export async function createEnvironmentVariable( + client: ManagementApiClient, + options: { + projectId: string; + branchId?: string; + className: "production" | "preview"; + key: string; + value: string; + signal?: AbortSignal; + }, +): Promise { + const result = await client.POST("/v1/environment-variables", { + body: { + projectId: options.projectId, + class: options.className, + key: options.key, + value: options.value, + ...(options.branchId ? { branchId: options.branchId } : {}), + }, + signal: options.signal, + }); + + if (result.error || !result.data) { + throw apiCallError(`Failed to add ${options.key}`, result.response, result.error); + } + + return normalizeEnvironmentVariable(result.data.data as RawEnvironmentVariableRecord); +} + +function normalizeEnvironmentVariable(variable: RawEnvironmentVariableRecord): PreviewEnvironmentVariableRecord { + return { + id: variable.id, + key: variable.key, + branchId: variable.branchId, + className: variable.class, + isManagedBySystem: variable.isManagedBySystem, + }; +} + +function normalizeBranchDatabaseRecord(database: RawDatabaseRecord): PreviewBranchDatabaseRecord { + const connection = database.connections?.[0]; + const databaseUrl = connection?.endpoints?.pooled?.connectionString; + const directUrl = connection?.endpoints?.direct?.connectionString ?? null; + + if (!databaseUrl) { + throw new Error("Created database did not return a pooled connection string."); + } + + return { + id: database.id, + name: database.name, + branchId: database.branchId, + databaseUrl, + directUrl, + }; +} + +function apiCallError( + summary: string, + response: Response, + error: RawApiErrorBody, +): Error { + if (response.status === 404) { + return new Error("Resource Not Found"); + } + + const message = error.error?.message ?? `Management API returned HTTP ${response.status}.`; + const hint = error.error?.hint ? ` ${error.error.hint}` : ""; + return new Error(`${summary}: ${message}${hint}`); +} diff --git a/packages/cli/src/lib/app/preview-provider.ts b/packages/cli/src/lib/app/preview-provider.ts index 6831563..5f28870 100644 --- a/packages/cli/src/lib/app/preview-provider.ts +++ b/packages/cli/src/lib/app/preview-provider.ts @@ -8,6 +8,13 @@ import { envVarNames } from "./env-vars"; import { PreviewBuildStrategy } from "./preview-build"; import type { PreviewBuildType } from "./preview-build"; import type { BranchKind } from "../../types/branch"; +import { + createBranchDatabase, + createEnvironmentVariable, + listEnvironmentVariables, + type PreviewBranchDatabaseRecord, + type PreviewEnvironmentVariableRecord, +} from "./preview-branch-database"; export interface PreviewAppRecord { id: string; @@ -29,6 +36,8 @@ export interface PreviewBranchRecord { role: BranchKind; } +export type { PreviewBranchDatabaseRecord, PreviewEnvironmentVariableRecord } from "./preview-branch-database"; + export interface PreviewDeploymentRecord { id: string; status: string; @@ -113,6 +122,27 @@ export class PreviewDomainApiError extends Error { export interface PreviewAppProvider { createProject(options: { name: string; signal?: AbortSignal }): Promise; resolveBranch(projectId: string, options: { branchName: string; signal?: AbortSignal }): Promise; + createBranchDatabase(options: { + projectId: string; + branchId: string; + branchName: string; + signal?: AbortSignal; + }): Promise; + listEnvironmentVariables(options: { + projectId: string; + className?: "production" | "preview"; + key?: string; + branchId?: string; + signal?: AbortSignal; + }): Promise; + createEnvironmentVariable(options: { + projectId: string; + branchId?: string; + className: "production" | "preview"; + key: string; + value: string; + signal?: AbortSignal; + }): Promise; listApps(projectId: string, options?: { branchName?: string; signal?: AbortSignal }): Promise; removeApp(appId: string, options?: { signal?: AbortSignal }): Promise; listDomains(appId: string, options?: { signal?: AbortSignal }): Promise; @@ -209,6 +239,18 @@ export function createPreviewAppProvider( }; }, + async createBranchDatabase(options) { + return createBranchDatabase(client, options); + }, + + async listEnvironmentVariables(options) { + return listEnvironmentVariables(client, options); + }, + + async createEnvironmentVariable(options) { + return createEnvironmentVariable(client, options); + }, + async removeApp(appId, options) { const appResult = await sdk.showService({ serviceId: appId, signal: options?.signal }); if (appResult.isErr()) { diff --git a/packages/cli/src/presenters/app.ts b/packages/cli/src/presenters/app.ts index e6debe4..a55cfb0 100644 --- a/packages/cli/src/presenters/app.ts +++ b/packages/cli/src/presenters/app.ts @@ -54,6 +54,7 @@ export function renderAppDeploy( const lines = [ `Live in ${formatDuration(result.durationMs)}`, ...(result.deployment.url ? [context.ui.link(result.deployment.url)] : []), + ...renderBranchDatabaseDeploySummary(context, result), "", ...renderDeployOutputRows(context.ui, [ { label: "Logs", value: "prisma-cli app logs" }, @@ -67,6 +68,34 @@ export function serializeAppDeploy(result: AppDeployResult) { return serialized; } +function renderBranchDatabaseDeploySummary( + context: CommandContext, + result: AppDeployResult, +): string[] { + if (!result.branchDatabase || result.branchDatabase.status !== "created") { + return []; + } + + return [ + "", + ...renderDeployOutputRows(context.ui, [ + { label: "Database", value: result.branchDatabase.database?.name ?? "created" }, + { + label: "Env", + value: result.branchDatabase.envVars.join(", "), + }, + ...(result.branchDatabase.schema + ? [{ + label: "Schema", + value: result.branchDatabase.schema.command === "migrate-deploy" + ? "prisma migrate deploy" + : "prisma db push", + }] + : []), + ]), + ]; +} + function formatDuration(durationMs: number): string { if (durationMs < 1000) { return `${durationMs}ms`; diff --git a/packages/cli/src/shell/command-meta.ts b/packages/cli/src/shell/command-meta.ts index 2201930..4eab405 100644 --- a/packages/cli/src/shell/command-meta.ts +++ b/packages/cli/src/shell/command-meta.ts @@ -146,6 +146,8 @@ const DESCRIPTORS: CommandDescriptor[] = [ "prisma-cli app deploy --project proj_123", "prisma-cli app deploy --create-project my-app --yes", "prisma-cli app deploy --app my-app --env DATABASE_URL=postgresql://example", + "prisma-cli app deploy --db", + "prisma-cli app deploy --db --yes", "prisma-cli app deploy --app my-app --framework nextjs --http-port 3000", "prisma-cli app deploy --branch feat-login --framework hono", "prisma-cli app deploy --prod --yes", diff --git a/packages/cli/src/types/app.ts b/packages/cli/src/types/app.ts index 8f590b1..7038437 100644 --- a/packages/cli/src/types/app.ts +++ b/packages/cli/src/types/app.ts @@ -23,6 +23,19 @@ export interface AppDeployResult { kind: BranchKind; }; resolution: ProjectResolution; + branchDatabase?: { + status: "created" | "skipped"; + reason?: string; + database?: { + id: string; + name: string; + }; + envVars: string[]; + schema: { + command: "migrate-deploy" | "db-push"; + path: string; + } | null; + }; app: AppSummary; deployment: { id: string; diff --git a/packages/cli/tests/app-branch-database.test.ts b/packages/cli/tests/app-branch-database.test.ts new file mode 100644 index 0000000..066ebed --- /dev/null +++ b/packages/cli/tests/app-branch-database.test.ts @@ -0,0 +1,504 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +beforeEach(() => { + process.env.PRISMA_CLI_TEST_REMEMBER_PROJECT_ID = "proj_123"; + process.env.PRISMA_CLI_TEST_REMEMBER_PROJECT_NAME = "Acme Dashboard"; + process.env.PRISMA_CLI_TEST_REMEMBER_WORKSPACE_ID = "ws_123"; + + vi.doMock("../src/lib/auth/auth-ops", () => ({ + readAuthState: vi.fn().mockResolvedValue({ + authenticated: true, + provider: null, + user: { + email: "test@example.com", + }, + workspace: { + id: "ws_123", + name: "Acme Inc", + }, + }), + performLogin: vi.fn(), + performLogout: vi.fn(), + })); +}); + +afterEach(() => { + delete process.env.PRISMA_CLI_TEST_REMEMBER_PROJECT_ID; + delete process.env.PRISMA_CLI_TEST_REMEMBER_PROJECT_NAME; + delete process.env.PRISMA_CLI_TEST_REMEMBER_WORKSPACE_ID; + + vi.doUnmock("../src/lib/auth/auth-ops"); + vi.doUnmock("../src/lib/auth/guard"); + vi.doUnmock("../src/lib/app/preview-provider"); + vi.doUnmock("../src/lib/app/branch-database"); + vi.doUnmock("../src/lib/app/branch-database-deploy"); + vi.doUnmock("../src/shell/prompt"); + vi.resetModules(); + vi.restoreAllMocks(); +}); + +function createProjectClient( + projectId = "proj_123", + options: { + branchExists?: boolean; + isDefault?: boolean; + } = {}, +) { + const branchRecord = (branchName: string) => ({ + id: `branch_${branchName.replace(/[^a-z0-9]+/gi, "_")}`, + gitName: branchName, + isDefault: options.isDefault ?? branchName === "main", + role: "preview", + }); + + return { + token: "token", + GET: vi.fn().mockImplementation((pathName: string, request?: { params?: { query?: { gitName?: string } } }) => { + if (pathName === "/v1/projects") { + return { + data: { + data: [ + { + id: projectId, + name: projectId === "proj_456" ? "Billing API" : "Acme Dashboard", + slug: projectId === "proj_456" ? "billing-api" : "acme-dashboard", + workspace: { + id: "ws_123", + name: "Acme Inc", + }, + }, + ], + }, + }; + } + + if (pathName === "/v1/projects/{projectId}/branches") { + const branchName = request?.params?.query?.gitName ?? "main"; + return { + data: { + data: options.branchExists === false ? [] : [branchRecord(branchName)], + }, + }; + } + + throw new Error(`Unexpected path ${pathName}`); + }), + POST: vi.fn().mockImplementation((pathName: string, request?: { body?: { gitName?: string } }) => { + if (pathName === "/v1/projects/{projectId}/branches") { + const branchName = request?.body?.gitName ?? "main"; + return { + data: { + data: branchRecord(branchName), + }, + }; + } + + throw new Error(`Unexpected path ${pathName}`); + }), + }; +} + +function createResolveBranch(role: "preview" | "production" = "preview") { + return vi.fn().mockImplementation((_projectId: string, options: { branchName: string }) => Promise.resolve({ + id: `branch_${options.branchName.replace(/[^a-z0-9]+/gi, "_")}`, + name: options.branchName, + role, + })); +} + +describe("app deploy branch database setup", () => { + 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"; + 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/db", + 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 listEnvironmentVariables = vi.fn().mockResolvedValue([]); + 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", + }, + }); + const runBranchDatabaseSchemaSetup = vi.fn().mockResolvedValue({ + command: "db-push", + schemaPath: "prisma/schema.prisma", + }); + + 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/db", + role: "preview", + }), + listApps, + createBranchDatabase, + listEnvironmentVariables, + createEnvironmentVariable, + 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 mkdir(path.join(cwd, "prisma"), { recursive: true }); + await writeFile(path.join(cwd, "prisma/schema.prisma"), "datasource db { provider = \"postgresql\" url = env(\"DATABASE_URL\") }\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/db", + framework: "hono", + db: true, + }); + + expect(createBranchDatabase).toHaveBeenCalledWith({ + projectId: "proj_123", + branchId, + branchName: "feature/db", + signal: context.runtime.signal, + }); + expect(runBranchDatabaseSchemaSetup.mock.calls[0]?.[0].context).toBe(context); + expect(runBranchDatabaseSchemaSetup.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ + databaseUrl: "postgres://pooled", + directUrl: "postgres://direct", + }), + ); + expect(createEnvironmentVariable).toHaveBeenCalledWith( + expect.objectContaining({ + projectId: "proj_123", + branchId, + className: "preview", + key: "DATABASE_URL", + value: "postgres://pooled", + }), + ); + expect(createEnvironmentVariable).toHaveBeenCalledWith( + expect.objectContaining({ + projectId: "proj_123", + branchId, + className: "preview", + key: "DIRECT_URL", + value: "postgres://direct", + }), + ); + expect(createBranchDatabase.mock.invocationCallOrder[0]).toBeLessThan(deployApp.mock.invocationCallOrder[0]); + expect(runBranchDatabaseSchemaSetup.mock.invocationCallOrder[0]).toBeLessThan(deployApp.mock.invocationCallOrder[0]); + expect(deployApp).toHaveBeenCalledWith( + expect.objectContaining({ + projectId: "proj_123", + appId: "app_1", + envVars: undefined, + }), + ); + expect(result.result.branchDatabase).toEqual({ + status: "created", + database: { + id: "db_1", + name: "feature/db", + }, + envVars: ["DATABASE_URL", "DIRECT_URL"], + schema: { + command: "db-push", + path: "prisma/schema.prisma", + }, + }); + }); + + it("deploy --db leaves an existing branch DATABASE_URL override unchanged", async () => { + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const branchId = "branch_feature_db"; + const listApps = vi.fn().mockResolvedValue([ + { id: "app_1", name: "hello-world", region: "eu-central-1", liveDeploymentId: null, liveUrl: null }, + ]); + const createBranchDatabase = vi.fn(); + const createEnvironmentVariable = vi.fn(); + const deployApp = vi.fn().mockResolvedValue({ + projectId: "proj_123", + app: { + id: "app_1", + name: "hello-world", + region: "eu-central-1", + liveDeploymentId: "dep_123", + }, + deployment: { + id: "dep_123", + status: "running", + url: "https://hello-world.prisma.app", + }, + }); + const listEnvironmentVariables = vi.fn().mockImplementation(async (options: { key?: string }) => { + if (options.key === "DATABASE_URL") { + return [{ + id: "env_database_url", + key: "DATABASE_URL", + branchId, + className: "preview", + isManagedBySystem: false, + }]; + } + return []; + }); + + vi.doMock("../src/lib/auth/guard", () => ({ + requireComputeAuth, + })); + vi.doMock("../src/lib/app/preview-provider", () => ({ + createPreviewAppProvider: vi.fn(() => ({ + resolveBranch: vi.fn().mockResolvedValue({ + id: branchId, + name: "feature/db", + role: "preview", + }), + listApps, + createBranchDatabase, + listEnvironmentVariables, + createEnvironmentVariable, + 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 mkdir(path.join(cwd, "prisma"), { recursive: true }); + await writeFile(path.join(cwd, "prisma/schema.prisma"), "datasource db { provider = \"postgresql\" url = env(\"DATABASE_URL\") }\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/db", + framework: "hono", + db: true, + }); + + expect(createBranchDatabase).not.toHaveBeenCalled(); + expect(createEnvironmentVariable).not.toHaveBeenCalled(); + expect(deployApp).toHaveBeenCalled(); + expect(result.result.branchDatabase).toEqual({ + status: "skipped", + reason: "branch-env-exists", + envVars: ["DATABASE_URL"], + schema: null, + }); + }); + + it("prompts for branch database setup when a preview deploy appears to use a database", async () => { + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const branchId = "branch_feature_db"; + const confirmPrompt = vi.fn().mockResolvedValue(true); + 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/db", + branchId, + databaseUrl: "postgres://pooled", + directUrl: "postgres://direct", + }); + const createEnvironmentVariable = vi.fn().mockResolvedValue({ + id: "env_database_url", + key: "DATABASE_URL", + branchId, + className: "preview", + isManagedBySystem: false, + }); + const deployApp = vi.fn().mockResolvedValue({ + projectId: "proj_123", + app: { + id: "app_1", + name: "hello-world", + region: "eu-central-1", + liveDeploymentId: "dep_123", + }, + deployment: { + id: "dep_123", + status: "running", + url: "https://hello-world.prisma.app", + }, + }); + + vi.doMock("../src/lib/auth/guard", () => ({ + requireComputeAuth, + })); + vi.doMock("../src/shell/prompt", async () => { + const actual = await vi.importActual("../src/shell/prompt"); + return { + ...actual, + confirmPrompt, + }; + }); + vi.doMock("../src/lib/app/branch-database", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + runBranchDatabaseSchemaSetup: vi.fn().mockResolvedValue({ + command: "db-push", + schemaPath: "prisma/schema.prisma", + }), + }; + }); + vi.doMock("../src/lib/app/preview-provider", () => ({ + createPreviewAppProvider: vi.fn(() => ({ + resolveBranch: vi.fn().mockResolvedValue({ + id: branchId, + name: "feature/db", + role: "preview", + }), + listApps, + createBranchDatabase, + listEnvironmentVariables: vi.fn().mockResolvedValue([]), + createEnvironmentVariable, + 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 mkdir(path.join(cwd, "prisma"), { recursive: true }); + await writeFile(path.join(cwd, "prisma/schema.prisma"), "datasource db { provider = \"postgresql\" url = env(\"DATABASE_URL\") }\n"); + const { context } = await createTestCommandContext({ + cwd, + stateDir: path.join(cwd, ".state"), + isTTY: true, + env: { + ...process.env, + PRISMA_CLI_MOCK_FIXTURE_PATH: undefined, + }, + }); + + await runAppDeploy(context, "hello-world", { + projectRef: "proj_123", + branchName: "feature/db", + framework: "hono", + }); + + expect(confirmPrompt).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Create an isolated database for branch "feature/db"?', + initialValue: false, + }), + ); + expect(createBranchDatabase).toHaveBeenCalled(); + }); + + it("rejects --db when deploy also passes inline database env vars", 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(), + deployApp, + listDeployments: vi.fn(), + showDeployment: vi.fn(), + })), + })); + + const { createTempCwd, createTestCommandContext } = await import("./helpers"); + const { runAppDeploy } = await import("../src/controllers/app"); + const cwd = await createTempCwd(); + 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", + envAssignments: ["DATABASE_URL=postgresql://example"], + db: true, + })).rejects.toMatchObject({ + code: "USAGE_ERROR", + domain: "app", + summary: "Branch database setup cannot be combined with inline database env vars", + }); + expect(createBranchDatabase).not.toHaveBeenCalled(); + expect(deployApp).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/tests/app-controller.test.ts b/packages/cli/tests/app-controller.test.ts index 1e1ef12..1762f2a 100644 --- a/packages/cli/tests/app-controller.test.ts +++ b/packages/cli/tests/app-controller.test.ts @@ -33,6 +33,7 @@ afterEach(() => { vi.doUnmock("../src/lib/auth/auth-ops"); vi.doUnmock("../src/lib/auth/guard"); vi.doUnmock("../src/lib/app/preview-provider"); + vi.doUnmock("../src/lib/app/branch-database"); vi.doUnmock("../src/shell/prompt"); vi.doUnmock("open"); vi.resetModules(); @@ -108,6 +109,15 @@ function createResolveBranch(role: "preview" | "production" = "preview") { })); } +function withBranchDatabaseProviderDefaults>(provider: T) { + return { + createBranchDatabase: vi.fn(), + listEnvironmentVariables: vi.fn().mockResolvedValue([]), + createEnvironmentVariable: vi.fn(), + ...provider, + }; +} + function createDomain(overrides: Partial<{ id: string; hostname: string; @@ -200,7 +210,7 @@ describe("app controller", () => { requireComputeAuth, })); vi.doMock("../src/lib/app/preview-provider", () => ({ - createPreviewAppProvider: vi.fn(() => ({ + createPreviewAppProvider: vi.fn(() => withBranchDatabaseProviderDefaults({ resolveBranch: createResolveBranch(), listApps, deployApp, @@ -289,7 +299,7 @@ describe("app controller", () => { requireComputeAuth, })); vi.doMock("../src/lib/app/preview-provider", () => ({ - createPreviewAppProvider: vi.fn(() => ({ + createPreviewAppProvider: vi.fn(() => withBranchDatabaseProviderDefaults({ resolveBranch: createResolveBranch(), listApps, deployApp, @@ -342,7 +352,7 @@ describe("app controller", () => { requireComputeAuth, })); vi.doMock("../src/lib/app/preview-provider", () => ({ - createPreviewAppProvider: vi.fn(() => ({ + createPreviewAppProvider: vi.fn(() => withBranchDatabaseProviderDefaults({ resolveBranch: createResolveBranch(), createProject: vi.fn(), listApps, @@ -410,7 +420,7 @@ describe("app controller", () => { const actual = await importOriginal(); return { ...actual, - createPreviewAppProvider: vi.fn(() => ({ + createPreviewAppProvider: vi.fn(() => withBranchDatabaseProviderDefaults({ resolveBranch: createResolveBranch(), listApps, listDomains: vi.fn(), @@ -493,7 +503,7 @@ describe("app controller", () => { const actual = await importOriginal(); return { ...actual, - createPreviewAppProvider: vi.fn(() => ({ + createPreviewAppProvider: vi.fn(() => withBranchDatabaseProviderDefaults({ resolveBranch: createResolveBranch(), listApps, addDomain, @@ -545,7 +555,7 @@ describe("app controller", () => { const actual = await importOriginal(); return { ...actual, - createPreviewAppProvider: vi.fn(() => ({ + createPreviewAppProvider: vi.fn(() => withBranchDatabaseProviderDefaults({ resolveBranch: createResolveBranch(), createProject, listApps, @@ -617,7 +627,7 @@ describe("app controller", () => { const actual = await importOriginal(); return { ...actual, - createPreviewAppProvider: vi.fn(() => ({ + createPreviewAppProvider: vi.fn(() => withBranchDatabaseProviderDefaults({ resolveBranch: createResolveBranch(), listApps, addDomain, @@ -671,7 +681,7 @@ describe("app controller", () => { })); return { ...actual, - createPreviewAppProvider: vi.fn(() => ({ + createPreviewAppProvider: vi.fn(() => withBranchDatabaseProviderDefaults({ resolveBranch: createResolveBranch(), listApps, addDomain, @@ -726,7 +736,7 @@ describe("app controller", () => { })); return { ...actual, - createPreviewAppProvider: vi.fn(() => ({ + createPreviewAppProvider: vi.fn(() => withBranchDatabaseProviderDefaults({ resolveBranch: createResolveBranch(), listApps, addDomain, @@ -787,7 +797,7 @@ describe("app controller", () => { })); return { ...actual, - createPreviewAppProvider: vi.fn(() => ({ + createPreviewAppProvider: vi.fn(() => withBranchDatabaseProviderDefaults({ resolveBranch: createResolveBranch(), listApps, addDomain, @@ -847,7 +857,7 @@ describe("app controller", () => { })); return { ...actual, - createPreviewAppProvider: vi.fn(() => ({ + createPreviewAppProvider: vi.fn(() => withBranchDatabaseProviderDefaults({ resolveBranch: createResolveBranch(), listApps, addDomain, @@ -899,7 +909,7 @@ describe("app controller", () => { const actual = await importOriginal(); return { ...actual, - createPreviewAppProvider: vi.fn(() => ({ + createPreviewAppProvider: vi.fn(() => withBranchDatabaseProviderDefaults({ resolveBranch: createResolveBranch(), listApps, listDomains, @@ -976,7 +986,7 @@ describe("app controller", () => { })); return { ...actual, - createPreviewAppProvider: vi.fn(() => ({ + createPreviewAppProvider: vi.fn(() => withBranchDatabaseProviderDefaults({ resolveBranch: createResolveBranch(), listApps, listDomains, @@ -1024,7 +1034,7 @@ describe("app controller", () => { const actual = await importOriginal(); return { ...actual, - createPreviewAppProvider: vi.fn(() => ({ + createPreviewAppProvider: vi.fn(() => withBranchDatabaseProviderDefaults({ resolveBranch: createResolveBranch(), listApps, listDomains, @@ -1125,7 +1135,7 @@ describe("app controller", () => { requireComputeAuth, })); vi.doMock("../src/lib/app/preview-provider", () => ({ - createPreviewAppProvider: vi.fn(() => ({ + createPreviewAppProvider: vi.fn(() => withBranchDatabaseProviderDefaults({ resolveBranch: createResolveBranch(), createProject: vi.fn(), listApps, @@ -1219,7 +1229,7 @@ describe("app controller", () => { requireComputeAuth, })); vi.doMock("../src/lib/app/preview-provider", () => ({ - createPreviewAppProvider: vi.fn(() => ({ + createPreviewAppProvider: vi.fn(() => withBranchDatabaseProviderDefaults({ resolveBranch: createResolveBranch(), createProject: vi.fn(), listApps, @@ -1277,7 +1287,7 @@ describe("app controller", () => { requireComputeAuth, })); vi.doMock("../src/lib/app/preview-provider", () => ({ - createPreviewAppProvider: vi.fn(() => ({ + createPreviewAppProvider: vi.fn(() => withBranchDatabaseProviderDefaults({ resolveBranch: createResolveBranch(), createProject: vi.fn(), listApps, @@ -1364,7 +1374,7 @@ describe("app controller", () => { requireComputeAuth, })); vi.doMock("../src/lib/app/preview-provider", () => ({ - createPreviewAppProvider: vi.fn(() => ({ + createPreviewAppProvider: vi.fn(() => withBranchDatabaseProviderDefaults({ resolveBranch: createResolveBranch(), listApps, deployApp, @@ -1443,7 +1453,7 @@ describe("app controller", () => { requireComputeAuth, })); vi.doMock("../src/lib/app/preview-provider", () => ({ - createPreviewAppProvider: vi.fn(() => ({ + createPreviewAppProvider: vi.fn(() => withBranchDatabaseProviderDefaults({ resolveBranch: createResolveBranch(), listApps, deployApp, @@ -1558,7 +1568,7 @@ describe("app controller", () => { requireComputeAuth, })); vi.doMock("../src/lib/app/preview-provider", () => ({ - createPreviewAppProvider: vi.fn(() => ({ + createPreviewAppProvider: vi.fn(() => withBranchDatabaseProviderDefaults({ resolveBranch: createResolveBranch(), listApps, deployApp, @@ -1627,7 +1637,7 @@ describe("app controller", () => { requireComputeAuth, })); vi.doMock("../src/lib/app/preview-provider", () => ({ - createPreviewAppProvider: vi.fn(() => ({ + createPreviewAppProvider: vi.fn(() => withBranchDatabaseProviderDefaults({ resolveBranch: createResolveBranch(), listApps, deployApp, @@ -1686,7 +1696,7 @@ describe("app controller", () => { requireComputeAuth, })); vi.doMock("../src/lib/app/preview-provider", () => ({ - createPreviewAppProvider: vi.fn(() => ({ + createPreviewAppProvider: vi.fn(() => withBranchDatabaseProviderDefaults({ resolveBranch: createResolveBranch(), createProject, listApps, @@ -1828,7 +1838,7 @@ describe("app controller", () => { requireComputeAuth, })); vi.doMock("../src/lib/app/preview-provider", () => ({ - createPreviewAppProvider: vi.fn(() => ({ + createPreviewAppProvider: vi.fn(() => withBranchDatabaseProviderDefaults({ resolveBranch: createResolveBranch(), createProject, listApps, @@ -1895,7 +1905,7 @@ describe("app controller", () => { requireComputeAuth, })); vi.doMock("../src/lib/app/preview-provider", () => ({ - createPreviewAppProvider: vi.fn(() => ({ + createPreviewAppProvider: vi.fn(() => withBranchDatabaseProviderDefaults({ createProject, resolveBranch: createResolveBranch(), listApps, @@ -1977,7 +1987,7 @@ describe("app controller", () => { requireComputeAuth, })); vi.doMock("../src/lib/app/preview-provider", () => ({ - createPreviewAppProvider: vi.fn(() => ({ + createPreviewAppProvider: vi.fn(() => withBranchDatabaseProviderDefaults({ resolveBranch: createResolveBranch(), createProject, listApps, @@ -2044,7 +2054,7 @@ describe("app controller", () => { requireComputeAuth, })); vi.doMock("../src/lib/app/preview-provider", () => ({ - createPreviewAppProvider: vi.fn(() => ({ + createPreviewAppProvider: vi.fn(() => withBranchDatabaseProviderDefaults({ resolveBranch: createResolveBranch(), listApps: vi.fn(), deployApp: vi.fn(), @@ -2084,7 +2094,7 @@ describe("app controller", () => { requireComputeAuth, })); vi.doMock("../src/lib/app/preview-provider", () => ({ - createPreviewAppProvider: vi.fn(() => ({ + createPreviewAppProvider: vi.fn(() => withBranchDatabaseProviderDefaults({ resolveBranch: createResolveBranch(), listApps, deployApp, @@ -2134,7 +2144,7 @@ describe("app controller", () => { requireComputeAuth, })); vi.doMock("../src/lib/app/preview-provider", () => ({ - createPreviewAppProvider: vi.fn(() => ({ + createPreviewAppProvider: vi.fn(() => withBranchDatabaseProviderDefaults({ resolveBranch: createResolveBranch(), listApps, deployApp, @@ -2212,7 +2222,7 @@ describe("app controller", () => { requireComputeAuth, })); vi.doMock("../src/lib/app/preview-provider", () => ({ - createPreviewAppProvider: vi.fn(() => ({ + createPreviewAppProvider: vi.fn(() => withBranchDatabaseProviderDefaults({ resolveBranch: createResolveBranch(), listApps, deployApp, @@ -2321,7 +2331,7 @@ describe("app controller", () => { requireComputeAuth, })); vi.doMock("../src/lib/app/preview-provider", () => ({ - createPreviewAppProvider: vi.fn(() => ({ + createPreviewAppProvider: vi.fn(() => withBranchDatabaseProviderDefaults({ resolveBranch: createResolveBranch(), listApps, deployApp, @@ -2385,7 +2395,7 @@ describe("app controller", () => { requireComputeAuth, })); vi.doMock("../src/lib/app/preview-provider", () => ({ - createPreviewAppProvider: vi.fn(() => ({ + createPreviewAppProvider: vi.fn(() => withBranchDatabaseProviderDefaults({ resolveBranch: createResolveBranch(), listApps, deployApp, @@ -2443,7 +2453,7 @@ describe("app controller", () => { requireComputeAuth, })); vi.doMock("../src/lib/app/preview-provider", () => ({ - createPreviewAppProvider: vi.fn(() => ({ + createPreviewAppProvider: vi.fn(() => withBranchDatabaseProviderDefaults({ resolveBranch: createResolveBranch(), listApps, deployApp, @@ -2508,7 +2518,7 @@ describe("app controller", () => { requireComputeAuth, })); vi.doMock("../src/lib/app/preview-provider", () => ({ - createPreviewAppProvider: vi.fn(() => ({ + createPreviewAppProvider: vi.fn(() => withBranchDatabaseProviderDefaults({ resolveBranch: createResolveBranch(), createProject, listApps, @@ -2623,7 +2633,7 @@ describe("app controller", () => { requireComputeAuth, })); vi.doMock("../src/lib/app/preview-provider", () => ({ - createPreviewAppProvider: vi.fn(() => ({ + createPreviewAppProvider: vi.fn(() => withBranchDatabaseProviderDefaults({ resolveBranch: createResolveBranch(), createProject, listApps, @@ -2741,7 +2751,7 @@ describe("app controller", () => { requireComputeAuth, })); vi.doMock("../src/lib/app/preview-provider", () => ({ - createPreviewAppProvider: vi.fn(() => ({ + createPreviewAppProvider: vi.fn(() => withBranchDatabaseProviderDefaults({ resolveBranch: createResolveBranch(), createProject, listApps: vi.fn().mockResolvedValue([]), @@ -2796,7 +2806,7 @@ describe("app controller", () => { requireComputeAuth, })); vi.doMock("../src/lib/app/preview-provider", () => ({ - createPreviewAppProvider: vi.fn(() => ({ + createPreviewAppProvider: vi.fn(() => withBranchDatabaseProviderDefaults({ resolveBranch: createResolveBranch(), createProject, listApps: vi.fn().mockResolvedValue([]), @@ -2842,7 +2852,7 @@ describe("app controller", () => { requireComputeAuth, })); vi.doMock("../src/lib/app/preview-provider", () => ({ - createPreviewAppProvider: vi.fn(() => ({ + createPreviewAppProvider: vi.fn(() => withBranchDatabaseProviderDefaults({ resolveBranch: createResolveBranch(), createProject, listApps: vi.fn().mockResolvedValue([]), @@ -2904,7 +2914,7 @@ describe("app controller", () => { requireComputeAuth, })); vi.doMock("../src/lib/app/preview-provider", () => ({ - createPreviewAppProvider: vi.fn(() => ({ + createPreviewAppProvider: vi.fn(() => withBranchDatabaseProviderDefaults({ resolveBranch: createResolveBranch(), listApps, deployApp, @@ -2978,7 +2988,7 @@ describe("app controller", () => { requireComputeAuth, })); vi.doMock("../src/lib/app/preview-provider", () => ({ - createPreviewAppProvider: vi.fn(() => ({ + createPreviewAppProvider: vi.fn(() => withBranchDatabaseProviderDefaults({ resolveBranch: createResolveBranch(), listApps, deployApp: vi.fn(), @@ -3014,7 +3024,7 @@ describe("app controller", () => { requireComputeAuth, })); vi.doMock("../src/lib/app/preview-provider", () => ({ - createPreviewAppProvider: vi.fn(() => ({ + createPreviewAppProvider: vi.fn(() => withBranchDatabaseProviderDefaults({ resolveBranch: createResolveBranch(), listApps, listDeployments: vi.fn(), @@ -3077,7 +3087,7 @@ describe("app controller", () => { requireComputeAuth, })); vi.doMock("../src/lib/app/preview-provider", () => ({ - createPreviewAppProvider: vi.fn(() => ({ + createPreviewAppProvider: vi.fn(() => withBranchDatabaseProviderDefaults({ resolveBranch: createResolveBranch(), listApps, deployApp: vi.fn(), @@ -3122,7 +3132,7 @@ describe("app controller", () => { requireComputeAuth, })); vi.doMock("../src/lib/app/preview-provider", () => ({ - createPreviewAppProvider: vi.fn(() => ({ + createPreviewAppProvider: vi.fn(() => withBranchDatabaseProviderDefaults({ resolveBranch: createResolveBranch(), createProject: vi.fn(), listApps, @@ -3179,7 +3189,7 @@ describe("app controller", () => { requireComputeAuth, })); vi.doMock("../src/lib/app/preview-provider", () => ({ - createPreviewAppProvider: vi.fn(() => ({ + createPreviewAppProvider: vi.fn(() => withBranchDatabaseProviderDefaults({ resolveBranch: createResolveBranch(), createProject: vi.fn(), listApps, @@ -3256,7 +3266,7 @@ describe("app controller", () => { requireComputeAuth, })); vi.doMock("../src/lib/app/preview-provider", () => ({ - createPreviewAppProvider: vi.fn(() => ({ + createPreviewAppProvider: vi.fn(() => withBranchDatabaseProviderDefaults({ resolveBranch: createResolveBranch(), createProject: vi.fn(), listApps, @@ -3357,7 +3367,7 @@ describe("app controller", () => { requireComputeAuth, })); vi.doMock("../src/lib/app/preview-provider", () => ({ - createPreviewAppProvider: vi.fn(() => ({ + createPreviewAppProvider: vi.fn(() => withBranchDatabaseProviderDefaults({ resolveBranch: createResolveBranch(), createProject: vi.fn(), listApps, @@ -3413,7 +3423,7 @@ describe("app controller", () => { requireComputeAuth, })); vi.doMock("../src/lib/app/preview-provider", () => ({ - createPreviewAppProvider: vi.fn(() => ({ + createPreviewAppProvider: vi.fn(() => withBranchDatabaseProviderDefaults({ resolveBranch: createResolveBranch(), listApps: vi.fn(), deployApp: vi.fn(), @@ -3474,7 +3484,7 @@ describe("app controller", () => { requireComputeAuth, })); vi.doMock("../src/lib/app/preview-provider", () => ({ - createPreviewAppProvider: vi.fn(() => ({ + createPreviewAppProvider: vi.fn(() => withBranchDatabaseProviderDefaults({ resolveBranch: createResolveBranch(), listApps: vi.fn(), deployApp: vi.fn(), @@ -3525,7 +3535,7 @@ describe("app controller", () => { requireComputeAuth, })); vi.doMock("../src/lib/app/preview-provider", () => ({ - createPreviewAppProvider: vi.fn(() => ({ + createPreviewAppProvider: vi.fn(() => withBranchDatabaseProviderDefaults({ resolveBranch: createResolveBranch(), listApps: vi.fn(), deployApp: vi.fn(), @@ -3567,7 +3577,7 @@ describe("app controller", () => { requireComputeAuth, })); vi.doMock("../src/lib/app/preview-provider", () => ({ - createPreviewAppProvider: vi.fn(() => ({ + createPreviewAppProvider: vi.fn(() => withBranchDatabaseProviderDefaults({ resolveBranch: createResolveBranch(), listApps: vi.fn(), deployApp: vi.fn(), @@ -3634,7 +3644,7 @@ describe("app controller", () => { requireComputeAuth, })); vi.doMock("../src/lib/app/preview-provider", () => ({ - createPreviewAppProvider: vi.fn(() => ({ + createPreviewAppProvider: vi.fn(() => withBranchDatabaseProviderDefaults({ resolveBranch: createResolveBranch(), createProject: vi.fn(), listApps, @@ -3704,7 +3714,7 @@ describe("app controller", () => { requireComputeAuth, })); vi.doMock("../src/lib/app/preview-provider", () => ({ - createPreviewAppProvider: vi.fn(() => ({ + createPreviewAppProvider: vi.fn(() => withBranchDatabaseProviderDefaults({ resolveBranch: createResolveBranch(), createProject: vi.fn(), listApps, @@ -3772,7 +3782,7 @@ describe("app controller", () => { requireComputeAuth, })); vi.doMock("../src/lib/app/preview-provider", () => ({ - createPreviewAppProvider: vi.fn(() => ({ + createPreviewAppProvider: vi.fn(() => withBranchDatabaseProviderDefaults({ resolveBranch: createResolveBranch(), createProject: vi.fn(), listApps, @@ -3837,7 +3847,7 @@ describe("app controller", () => { requireComputeAuth, })); vi.doMock("../src/lib/app/preview-provider", () => ({ - createPreviewAppProvider: vi.fn(() => ({ + createPreviewAppProvider: vi.fn(() => withBranchDatabaseProviderDefaults({ resolveBranch: createResolveBranch(), createProject: vi.fn(), listApps, @@ -3904,7 +3914,7 @@ describe("app controller", () => { requireComputeAuth, })); vi.doMock("../src/lib/app/preview-provider", () => ({ - createPreviewAppProvider: vi.fn(() => ({ + createPreviewAppProvider: vi.fn(() => withBranchDatabaseProviderDefaults({ resolveBranch: createResolveBranch(), createProject: vi.fn(), listApps, @@ -3981,7 +3991,7 @@ describe("app controller", () => { requireComputeAuth, })); vi.doMock("../src/lib/app/preview-provider", () => ({ - createPreviewAppProvider: vi.fn(() => ({ + createPreviewAppProvider: vi.fn(() => withBranchDatabaseProviderDefaults({ resolveBranch: createResolveBranch(), createProject: vi.fn(), listApps, @@ -4047,7 +4057,7 @@ describe("app controller", () => { requireComputeAuth, })); vi.doMock("../src/lib/app/preview-provider", () => ({ - createPreviewAppProvider: vi.fn(() => ({ + createPreviewAppProvider: vi.fn(() => withBranchDatabaseProviderDefaults({ resolveBranch: createResolveBranch(), createProject: vi.fn(), listApps, @@ -4132,7 +4142,7 @@ describe("app controller", () => { requireComputeAuth, })); vi.doMock("../src/lib/app/preview-provider", () => ({ - createPreviewAppProvider: vi.fn(() => ({ + createPreviewAppProvider: vi.fn(() => withBranchDatabaseProviderDefaults({ resolveBranch: createResolveBranch(), createProject: vi.fn(), listApps, @@ -4213,7 +4223,7 @@ describe("app controller", () => { requireComputeAuth, })); vi.doMock("../src/lib/app/preview-provider", () => ({ - createPreviewAppProvider: vi.fn(() => ({ + createPreviewAppProvider: vi.fn(() => withBranchDatabaseProviderDefaults({ resolveBranch: createResolveBranch(), createProject: vi.fn(), listApps, @@ -4276,7 +4286,7 @@ describe("app controller", () => { requireComputeAuth, })); vi.doMock("../src/lib/app/preview-provider", () => ({ - createPreviewAppProvider: vi.fn(() => ({ + createPreviewAppProvider: vi.fn(() => withBranchDatabaseProviderDefaults({ resolveBranch: createResolveBranch(), createProject: vi.fn(), listApps, @@ -4329,7 +4339,7 @@ describe("app controller", () => { requireComputeAuth, })); vi.doMock("../src/lib/app/preview-provider", () => ({ - createPreviewAppProvider: vi.fn(() => ({ + createPreviewAppProvider: vi.fn(() => withBranchDatabaseProviderDefaults({ resolveBranch: createResolveBranch(), listApps, deployApp, @@ -4393,7 +4403,7 @@ describe("app controller", () => { requireComputeAuth, })); vi.doMock("../src/lib/app/preview-provider", () => ({ - createPreviewAppProvider: vi.fn(() => ({ + createPreviewAppProvider: vi.fn(() => withBranchDatabaseProviderDefaults({ resolveBranch: createResolveBranch(), listApps, listDeployments, @@ -4447,7 +4457,7 @@ describe("app controller", () => { requireComputeAuth, })); vi.doMock("../src/lib/app/preview-provider", () => ({ - createPreviewAppProvider: vi.fn(() => ({ + createPreviewAppProvider: vi.fn(() => withBranchDatabaseProviderDefaults({ resolveBranch: createResolveBranch(), listApps, listDeployments, @@ -4495,7 +4505,7 @@ describe("app controller", () => { requireComputeAuth, })); vi.doMock("../src/lib/app/preview-provider", () => ({ - createPreviewAppProvider: vi.fn(() => ({ + createPreviewAppProvider: vi.fn(() => withBranchDatabaseProviderDefaults({ resolveBranch: createResolveBranch(), listApps, listDeployments, @@ -4545,7 +4555,7 @@ describe("app controller", () => { requireComputeAuth, })); vi.doMock("../src/lib/app/preview-provider", () => ({ - createPreviewAppProvider: vi.fn(() => ({ + createPreviewAppProvider: vi.fn(() => withBranchDatabaseProviderDefaults({ resolveBranch: createResolveBranch(), listApps, listDeployments, @@ -4606,7 +4616,7 @@ describe("app controller", () => { requireComputeAuth, })); vi.doMock("../src/lib/app/preview-provider", () => ({ - createPreviewAppProvider: vi.fn(() => ({ + createPreviewAppProvider: vi.fn(() => withBranchDatabaseProviderDefaults({ resolveBranch: createResolveBranch(), createProject: vi.fn(), listApps, @@ -4678,7 +4688,7 @@ describe("app controller", () => { }; }); vi.doMock("../src/lib/app/preview-provider", () => ({ - createPreviewAppProvider: vi.fn(() => ({ + createPreviewAppProvider: vi.fn(() => withBranchDatabaseProviderDefaults({ resolveBranch: createResolveBranch(), createProject: vi.fn(), listApps, @@ -4727,7 +4737,7 @@ describe("app controller", () => { requireComputeAuth, })); vi.doMock("../src/lib/app/preview-provider", () => ({ - createPreviewAppProvider: vi.fn(() => ({ + createPreviewAppProvider: vi.fn(() => withBranchDatabaseProviderDefaults({ resolveBranch: createResolveBranch(), createProject: vi.fn(), listApps, @@ -4773,7 +4783,7 @@ describe("app controller", () => { requireComputeAuth, })); vi.doMock("../src/lib/app/preview-provider", () => ({ - createPreviewAppProvider: vi.fn(() => ({ + createPreviewAppProvider: vi.fn(() => withBranchDatabaseProviderDefaults({ resolveBranch: createResolveBranch(), createProject: vi.fn(), listApps, @@ -4823,7 +4833,7 @@ describe("app controller", () => { requireComputeAuth, })); vi.doMock("../src/lib/app/preview-provider", () => ({ - createPreviewAppProvider: vi.fn(() => ({ + createPreviewAppProvider: vi.fn(() => withBranchDatabaseProviderDefaults({ resolveBranch: createResolveBranch(), createProject: vi.fn(), listApps, diff --git a/packages/cli/tests/app.test.ts b/packages/cli/tests/app.test.ts index c98d208..197bdbb 100644 --- a/packages/cli/tests/app.test.ts +++ b/packages/cli/tests/app.test.ts @@ -150,6 +150,8 @@ describe("app commands", () => { expect(deployHelp.stderr).toContain("$ prisma-cli app deploy"); expect(deployHelp.stderr).toContain("$ prisma-cli app deploy --project proj_123"); expect(deployHelp.stderr).toContain("$ prisma-cli app deploy --create-project my-app --yes"); + expect(deployHelp.stderr).toContain("$ prisma-cli app deploy --db"); + expect(deployHelp.stderr).toContain("$ prisma-cli app deploy --db --yes"); expect(deployHelp.stderr).toContain("$ prisma-cli app deploy --app my-app --framework nextjs --http-port 3000"); expect(deployHelp.stderr).toContain("$ pnpm dlx skills@latest add prisma/prisma-cli/skills#cli-v --all"); expect(deployHelp.stderr).toContain("--entry "); @@ -158,6 +160,8 @@ describe("app commands", () => { expect(deployHelp.stderr).not.toContain("--build-type "); expect(deployHelp.stderr).toContain("--http-port "); expect(deployHelp.stderr).toContain("--env "); + expect(deployHelp.stderr).toContain("--db"); + expect(deployHelp.stderr).toContain("--no-db"); expect(showHelp.exitCode).toBe(0); expect(showHelp.stderr).toContain("Show the app and its current deployment"); From 7a494c3508a96a04c88b38645b2e1a159b048041 Mon Sep 17 00:00:00 2001 From: Luan van der Westhuizen Date: Thu, 4 Jun 2026 07:51:17 +0200 Subject: [PATCH 2/4] fix: address branch database review feedback --- docs/product/command-spec.md | 4 +- docs/product/error-conventions.md | 2 + packages/cli/src/commands/app/index.ts | 43 ++- packages/cli/src/controllers/app.ts | 48 ++- .../cli/src/lib/app/branch-database-deploy.ts | 72 +++- packages/cli/src/lib/app/branch-database.ts | 40 ++- .../src/lib/app/preview-branch-database.ts | 25 ++ packages/cli/src/lib/app/preview-provider.ts | 10 + .../cli/tests/app-branch-database.test.ts | 309 ++++++++++++++---- packages/cli/tests/app-controller.test.ts | 72 +--- packages/cli/tests/app.test.ts | 15 + packages/cli/tests/helpers/mock-factories.ts | 70 ++++ 12 files changed, 531 insertions(+), 179 deletions(-) create mode 100644 packages/cli/tests/helpers/mock-factories.ts diff --git a/docs/product/command-spec.md b/docs/product/command-spec.md index 0d18178..f7982b9 100644 --- a/docs/product/command-spec.md +++ b/docs/product/command-spec.md @@ -631,9 +631,11 @@ Behavior: - 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 `--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 - branch database setup only runs for preview Branches; production database env vars are managed with `project env` -- branch database setup never overwrites an existing branch-scoped `DATABASE_URL`; when the branch already has one, `--db` leaves it unchanged and continues +- branch database setup never overwrites fully wired branch database env vars; when the branch already has both `DATABASE_URL` and `DIRECT_URL`, `--db` leaves them unchanged and continues +- when only one branch database env var exists, explicit `--db` treats it as partial setup and repairs the pair by writing fresh branch database env values - 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 diff --git a/docs/product/error-conventions.md b/docs/product/error-conventions.md index 0d56877..e9a7b4f 100644 --- a/docs/product/error-conventions.md +++ b/docs/product/error-conventions.md @@ -193,6 +193,7 @@ These codes are the minimum stable set for the MVP: - `REPO_ALREADY_CONNECTED` - `REPO_CONNECTION_FAILED` - `BUILD_FAILED` +- `BRANCH_RESOLUTION_FAILED` - `BRANCH_DATABASE_SETUP_FAILED` - `SCHEMA_SETUP_FAILED` - `RUN_FAILED` @@ -237,6 +238,7 @@ Recommended meanings: - `REPO_ALREADY_CONNECTED`: a project already has a different GitHub repository connected - `REPO_CONNECTION_FAILED`: the Management API repository connection operation failed - `BUILD_FAILED`: build failed before a healthy deployment existed +- `BRANCH_RESOLUTION_FAILED`: the CLI could not inspect the selected branch before running an app command - `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 - `RUN_FAILED`: local framework run command could not be started or exited unsuccessfully diff --git a/packages/cli/src/commands/app/index.ts b/packages/cli/src/commands/app/index.ts index 3f18f8f..109cfae 100644 --- a/packages/cli/src/commands/app/index.ts +++ b/packages/cli/src/commands/app/index.ts @@ -49,6 +49,7 @@ import { serializeAppShowDeploy, } from "../../presenters/app"; import { attachCommandDescriptor } from "../../shell/command-meta"; +import { usageError } from "../../shell/errors"; import { addCompactGlobalFlags, addGlobalFlags } from "../../shell/global-flags"; import { runCommand, runStreamingCommand } from "../../shell/command-runner"; import { configureRuntimeCommand, type CliRuntime } from "../../shell/runtime"; @@ -198,22 +199,38 @@ function createDeployCommand(runtime: CliRuntime): Command { const createProjectName = (options as { createProject?: string }).createProject; const prod = (options as { prod?: boolean }).prod; const db = (options as { db?: boolean }).db; + const hasDbConflict = hasFlag(runtime.argv, "--db") && hasFlag(runtime.argv, "--no-db"); await runCommand( runtime, "app.deploy", options as Record, - (context) => runAppDeploy(context, appName, { - projectRef, - createProjectName, - branchName, - entrypoint: entry, - framework, - httpPort, - envAssignments, - prod: prod === true, - db, - }), + (context) => { + if (hasDbConflict) { + throw usageError( + "app deploy accepts either --db or --no-db", + "--db requests branch database setup, while --no-db disables it.", + "Pass exactly one database setup flag.", + [ + "prisma-cli app deploy --db", + "prisma-cli app deploy --no-db", + ], + "app", + ); + } + + return runAppDeploy(context, appName, { + projectRef, + createProjectName, + branchName, + entrypoint: entry, + framework, + httpPort, + envAssignments, + prod: prod === true, + db, + }); + }, { renderHuman: (context, descriptor, result) => renderAppDeploy(context, descriptor, result), renderJson: (result) => serializeAppDeploy(result), @@ -224,6 +241,10 @@ function createDeployCommand(runtime: CliRuntime): Command { return command; } +function hasFlag(argv: string[], flag: string): boolean { + return argv.some((arg) => arg === flag || arg.startsWith(`${flag}=`)); +} + function createShowCommand(runtime: CliRuntime): Command { const command = attachCommandDescriptor( configureRuntimeCommand(new Command("show"), runtime), diff --git a/packages/cli/src/controllers/app.ts b/packages/cli/src/controllers/app.ts index 0f80524..2b73edf 100644 --- a/packages/cli/src/controllers/app.ts +++ b/packages/cli/src/controllers/app.ts @@ -79,7 +79,7 @@ import { type PreviewBuildType, } from "../lib/app/preview-build"; import { PREVIEW_DEFAULT_REGION } from "../lib/app/preview-interaction"; -import { maybeSetupBranchDatabase } from "../lib/app/branch-database-deploy"; +import { maybeSetupBranchDatabase, type BranchDatabaseDeployBranch } from "../lib/app/branch-database-deploy"; import { createPreviewDeployProgress, createPreviewDeployProgressState, @@ -322,7 +322,7 @@ export async function runAppDeploy( assertSupportedEntrypoint(buildType, options?.entrypoint, "deploy"); const entrypoint = await resolveDeployEntrypoint(context.runtime.cwd, framework, options?.entrypoint, context.runtime.signal); const portMapping = parseDeployPortMapping(String(runtime.port)); - const branchDatabaseSetup = await maybeSetupBranchDatabase(context, provider, projectId, target.branch, { + const branchDatabaseSetup = await maybeSetupBranchDatabase(context, provider, projectId, toBranchDatabaseDeployBranch(target.branch), { db: options?.db, inlineEnvVars: envVars, }); @@ -2206,7 +2206,7 @@ interface ResolvedAppProjectContext { workspace: AuthWorkspace; project: ProjectSummary; branch: { - id: string; + id: string | null; name: string; kind: BranchKind; }; @@ -2270,6 +2270,7 @@ async function resolveProjectContext( options?: { branch?: ResolvedDeployBranch; commandName?: string; + envProjectId?: string; }, ): Promise { const authState = await requireAuthenticatedAuthState(context); @@ -2290,6 +2291,7 @@ async function resolveProjectContext( return { ...resolved, branch: { + id: await resolveExistingAppBranchId(client, resolved.project.id, branch.name, context.runtime.signal), name: branch.name, kind: toBranchKind(branch.name), }, @@ -2489,6 +2491,34 @@ function toBranchKind(name: string): BranchKind { return name === "production" || name === "main" ? "production" : "preview"; } +async function resolveExistingAppBranchId( + client: ManagementApiClient, + projectId: string, + branchName: string, + signal: AbortSignal, +): Promise { + const result = await client.GET("/v1/projects/{projectId}/branches", { + params: { + path: { projectId }, + query: { gitName: branchName }, + }, + signal, + }); + if (result.error || !result.data) { + throw new CliError({ + code: "BRANCH_RESOLUTION_FAILED", + domain: "app", + summary: `Failed to resolve branch "${branchName}"`, + why: result.error instanceof Error ? result.error.message : `Management API returned HTTP ${result.response.status}.`, + fix: "Retry the command, or pass --branch with an existing Git branch name.", + exitCode: 1, + nextSteps: [`prisma-cli app deploy --branch ${formatCommandArgument(branchName)}`], + }); + } + + return result.data.data[0]?.id ?? null; +} + function toResultBranch(branch: ResolvedAppProjectContext["branch"]): AppDeployResult["branch"] { return { name: branch.name, @@ -2496,6 +2526,18 @@ function toResultBranch(branch: ResolvedAppProjectContext["branch"]): AppDeployR }; } +function toBranchDatabaseDeployBranch(branch: ResolvedAppProjectContext["branch"]): BranchDatabaseDeployBranch { + if (!branch.id) { + throw new Error(`Deploy branch "${branch.name}" was not resolved remotely.`); + } + + return { + id: branch.id, + name: branch.name, + kind: branch.kind, + }; +} + function assertExclusiveDeployProjectInputs(options: { projectRef: string | undefined; createProjectName: string | undefined; diff --git a/packages/cli/src/lib/app/branch-database-deploy.ts b/packages/cli/src/lib/app/branch-database-deploy.ts index 4ee20f7..c83c24b 100644 --- a/packages/cli/src/lib/app/branch-database-deploy.ts +++ b/packages/cli/src/lib/app/branch-database-deploy.ts @@ -92,7 +92,7 @@ export async function maybeSetupBranchDatabase( .map((variable) => variable.key) .sort(); - if (envState.branchDatabaseUrl) { + if (hasCompleteBranchDatabaseEnv(envState)) { const warning = options.db === true ? `Branch "${branch.name}" already has DATABASE_URL. Leaving branch database env vars unchanged.` : null; @@ -113,6 +113,10 @@ export async function maybeSetupBranchDatabase( }; } + if (options.db !== true && envState.branchDatabaseUrl) { + return emptyBranchDatabaseSetupOutcome(); + } + const hasSignal = hasBranchDatabaseSignal(localSignal) || Boolean(envState.previewDatabaseUrl); if (options.db !== true) { if (!hasSignal) { @@ -141,7 +145,7 @@ export async function maybeSetupBranchDatabase( } } - return setupBranchDatabase(context, provider, projectId, branch, localSignal); + return setupBranchDatabase(context, provider, projectId, branch, localSignal, envState); } async function setupBranchDatabase( @@ -150,6 +154,7 @@ async function setupBranchDatabase( projectId: string, branch: BranchDatabaseDeployBranch, signal: BranchDatabaseSignal, + envState: BranchDatabaseEnvState, ): Promise { emitBranchDatabaseProgress(context, "pending", "Creating branch database"); const database = await provider.createBranchDatabase({ @@ -180,7 +185,7 @@ async function setupBranchDatabase( skippedSchemaWarning = "No schema.prisma file was found. Branch database env vars were created, but schema setup was skipped."; } - const envVars = await createBranchDatabaseEnvVars(context, provider, projectId, branch, database); + const envVars = await upsertBranchDatabaseEnvVars(context, provider, projectId, branch, database, envState); emitBranchDatabaseProgress(context, "success", `Added branch env override${envVars.length === 1 ? "" : "s"} ${envVars.join(", ")}`); if (skippedSchemaWarning) { emitBranchDatabaseWarning(context, skippedSchemaWarning); @@ -206,41 +211,76 @@ async function setupBranchDatabase( }; } -async function createBranchDatabaseEnvVars( +async function upsertBranchDatabaseEnvVars( context: CommandContext, provider: PreviewAppProvider, projectId: string, branch: BranchDatabaseDeployBranch, database: PreviewBranchDatabaseRecord, + envState: BranchDatabaseEnvState, ): Promise { - const created: string[] = []; - await provider.createEnvironmentVariable({ + const written: string[] = []; + await upsertBranchDatabaseEnvVar(context, provider, { projectId, branchId: branch.id, className: "preview", key: "DATABASE_URL", value: database.databaseUrl, - signal: context.runtime.signal, - }).catch((error) => { - throw branchDatabaseSetupFailedError("Failed to write DATABASE_URL", error, branch.name); + existing: envState.branchDatabaseUrl, + branchName: branch.name, }); - created.push("DATABASE_URL"); + written.push("DATABASE_URL"); if (database.directUrl) { - await provider.createEnvironmentVariable({ + await upsertBranchDatabaseEnvVar(context, provider, { projectId, branchId: branch.id, className: "preview", key: "DIRECT_URL", value: database.directUrl, + existing: envState.branchDirectUrl, + branchName: branch.name, + }); + written.push("DIRECT_URL"); + } + + return written; +} + +async function upsertBranchDatabaseEnvVar( + context: CommandContext, + provider: PreviewAppProvider, + options: { + projectId: string; + branchId: string; + className: "preview"; + key: "DATABASE_URL" | "DIRECT_URL"; + value: string; + existing: PreviewEnvironmentVariableRecord | null; + branchName: string; + }, +): Promise { + if (options.existing) { + await provider.updateEnvironmentVariable({ + envVarId: options.existing.id, + value: options.value, signal: context.runtime.signal, }).catch((error) => { - throw branchDatabaseSetupFailedError("Failed to write DIRECT_URL", error, branch.name); + throw branchDatabaseSetupFailedError(`Failed to update ${options.key}`, error, options.branchName); }); - created.push("DIRECT_URL"); + return; } - return created; + await provider.createEnvironmentVariable({ + projectId: options.projectId, + branchId: options.branchId, + className: options.className, + key: options.key, + value: options.value, + signal: context.runtime.signal, + }).catch((error) => { + throw branchDatabaseSetupFailedError(`Failed to write ${options.key}`, error, options.branchName); + }); } async function inspectBranchDatabaseEnv( @@ -282,6 +322,10 @@ function hasInlineDatabaseEnvVars(envVars: Record | undefined): return Boolean(envVars && ("DATABASE_URL" in envVars || "DIRECT_URL" in envVars)); } +function hasCompleteBranchDatabaseEnv(envState: BranchDatabaseEnvState): boolean { + return Boolean(envState.branchDatabaseUrl && envState.branchDirectUrl); +} + function maybeRenderBranchDatabaseSignal( context: CommandContext, branchName: string, diff --git a/packages/cli/src/lib/app/branch-database.ts b/packages/cli/src/lib/app/branch-database.ts index 8457530..ae95daa 100644 --- a/packages/cli/src/lib/app/branch-database.ts +++ b/packages/cli/src/lib/app/branch-database.ts @@ -1,4 +1,5 @@ import { spawn } from "node:child_process"; +import type { Dirent } from "node:fs"; import { access, readdir, readFile, stat } from "node:fs/promises"; import path from "node:path"; @@ -58,18 +59,19 @@ export async function inspectBranchDatabaseSignal( ): Promise { const state: ScanState = { filesVisited: 0, - schemaPath: null, + schemaCandidates: [], databaseUrlReferences: [], }; await scanDirectory(cwd, cwd, 0, state, signal); - const hasMigrations = state.schemaPath - ? await hasMigrationsDirectory(path.dirname(state.schemaPath), signal) + const schemaPath = selectSchemaPath(cwd, state.schemaCandidates); + const hasMigrations = schemaPath + ? await hasMigrationsDirectory(path.dirname(schemaPath), signal) : false; - const schema = state.schemaPath + const schema = schemaPath ? { - path: state.schemaPath, + path: schemaPath, hasMigrations, command: hasMigrations ? "migrate-deploy" as const @@ -113,7 +115,7 @@ export async function runBranchDatabaseSchemaSetup(options: { interface ScanState { filesVisited: number; - schemaPath: string | null; + schemaCandidates: string[]; databaseUrlReferences: string[]; } @@ -130,7 +132,7 @@ async function scanDirectory( return; } - let entries: Awaited>; + let entries: Dirent[]; try { entries = await readdir(directory, { withFileTypes: true }); } catch (error) { @@ -139,6 +141,7 @@ async function scanDirectory( } throw error; } + entries.sort((left, right) => left.name.localeCompare(right.name)); for (const entry of entries) { signal.throwIfAborted(); @@ -160,8 +163,8 @@ async function scanDirectory( state.filesVisited += 1; - if (!state.schemaPath && entry.name === "schema.prisma") { - state.schemaPath = entryPath; + if (entry.name === "schema.prisma") { + state.schemaCandidates.push(entryPath); } if ( @@ -174,6 +177,20 @@ async function scanDirectory( } } +function selectSchemaPath(cwd: string, candidates: string[]): string | null { + return candidates + .map((candidate) => ({ + absolute: candidate, + relative: path.relative(cwd, candidate) || "schema.prisma", + })) + .sort((left, right) => { + if (left.relative === "schema.prisma") return -1; + if (right.relative === "schema.prisma") return 1; + return left.relative.length - right.relative.length + || left.relative.localeCompare(right.relative); + })[0]?.absolute ?? null; +} + async function hasMigrationsDirectory(schemaDirectory: string, signal: AbortSignal): Promise { signal.throwIfAborted(); const migrationsPath = path.join(schemaDirectory, "migrations"); @@ -222,6 +239,7 @@ async function runPrismaCommand(options: { args: string[]; env: Record; }): Promise { + const shouldPipeOutput = !options.context.flags.json && !options.context.flags.quiet; const child = spawn("npx", options.args, { cwd: options.context.runtime.cwd, env: { @@ -229,10 +247,10 @@ async function runPrismaCommand(options: { ...options.env, }, signal: options.context.runtime.signal, - stdio: ["ignore", "pipe", "pipe"], + stdio: shouldPipeOutput ? ["ignore", "pipe", "pipe"] : ["ignore", "ignore", "ignore"], }); - if (!options.context.flags.json && !options.context.flags.quiet) { + if (shouldPipeOutput) { child.stdout?.pipe(options.context.output.stderr, { end: false }); child.stderr?.pipe(options.context.output.stderr, { end: false }); } diff --git a/packages/cli/src/lib/app/preview-branch-database.ts b/packages/cli/src/lib/app/preview-branch-database.ts index 79fd20b..2d96f0b 100644 --- a/packages/cli/src/lib/app/preview-branch-database.ts +++ b/packages/cli/src/lib/app/preview-branch-database.ts @@ -146,6 +146,31 @@ export async function createEnvironmentVariable( return normalizeEnvironmentVariable(result.data.data as RawEnvironmentVariableRecord); } +export async function updateEnvironmentVariable( + client: ManagementApiClient, + options: { + envVarId: string; + value: string; + signal?: AbortSignal; + }, +): Promise { + const result = await client.PATCH("/v1/environment-variables/{envVarId}", { + params: { + path: { envVarId: options.envVarId }, + }, + body: { + value: options.value, + }, + signal: options.signal, + }); + + if (result.error || !result.data) { + throw apiCallError("Failed to update environment variable", result.response, result.error); + } + + return normalizeEnvironmentVariable(result.data.data as RawEnvironmentVariableRecord); +} + function normalizeEnvironmentVariable(variable: RawEnvironmentVariableRecord): PreviewEnvironmentVariableRecord { return { id: variable.id, diff --git a/packages/cli/src/lib/app/preview-provider.ts b/packages/cli/src/lib/app/preview-provider.ts index 5f28870..2b8921a 100644 --- a/packages/cli/src/lib/app/preview-provider.ts +++ b/packages/cli/src/lib/app/preview-provider.ts @@ -12,6 +12,7 @@ import { createBranchDatabase, createEnvironmentVariable, listEnvironmentVariables, + updateEnvironmentVariable, type PreviewBranchDatabaseRecord, type PreviewEnvironmentVariableRecord, } from "./preview-branch-database"; @@ -143,6 +144,11 @@ export interface PreviewAppProvider { value: string; signal?: AbortSignal; }): Promise; + updateEnvironmentVariable(options: { + envVarId: string; + value: string; + signal?: AbortSignal; + }): Promise; listApps(projectId: string, options?: { branchName?: string; signal?: AbortSignal }): Promise; removeApp(appId: string, options?: { signal?: AbortSignal }): Promise; listDomains(appId: string, options?: { signal?: AbortSignal }): Promise; @@ -251,6 +257,10 @@ export function createPreviewAppProvider( return createEnvironmentVariable(client, options); }, + async updateEnvironmentVariable(options) { + return updateEnvironmentVariable(client, options); + }, + async removeApp(appId, options) { const appResult = await sdk.showService({ serviceId: appId, signal: options?.signal }); if (appResult.isErr()) { diff --git a/packages/cli/tests/app-branch-database.test.ts b/packages/cli/tests/app-branch-database.test.ts index 066ebed..7c62bc4 100644 --- a/packages/cli/tests/app-branch-database.test.ts +++ b/packages/cli/tests/app-branch-database.test.ts @@ -3,6 +3,8 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createProjectClient, createResolveBranch } from "./helpers/mock-factories"; + beforeEach(() => { process.env.PRISMA_CLI_TEST_REMEMBER_PROJECT_ID = "proj_123"; process.env.PRISMA_CLI_TEST_REMEMBER_PROJECT_NAME = "Acme Dashboard"; @@ -40,75 +42,6 @@ afterEach(() => { vi.restoreAllMocks(); }); -function createProjectClient( - projectId = "proj_123", - options: { - branchExists?: boolean; - isDefault?: boolean; - } = {}, -) { - const branchRecord = (branchName: string) => ({ - id: `branch_${branchName.replace(/[^a-z0-9]+/gi, "_")}`, - gitName: branchName, - isDefault: options.isDefault ?? branchName === "main", - role: "preview", - }); - - return { - token: "token", - GET: vi.fn().mockImplementation((pathName: string, request?: { params?: { query?: { gitName?: string } } }) => { - if (pathName === "/v1/projects") { - return { - data: { - data: [ - { - id: projectId, - name: projectId === "proj_456" ? "Billing API" : "Acme Dashboard", - slug: projectId === "proj_456" ? "billing-api" : "acme-dashboard", - workspace: { - id: "ws_123", - name: "Acme Inc", - }, - }, - ], - }, - }; - } - - if (pathName === "/v1/projects/{projectId}/branches") { - const branchName = request?.params?.query?.gitName ?? "main"; - return { - data: { - data: options.branchExists === false ? [] : [branchRecord(branchName)], - }, - }; - } - - throw new Error(`Unexpected path ${pathName}`); - }), - POST: vi.fn().mockImplementation((pathName: string, request?: { body?: { gitName?: string } }) => { - if (pathName === "/v1/projects/{projectId}/branches") { - const branchName = request?.body?.gitName ?? "main"; - return { - data: { - data: branchRecord(branchName), - }, - }; - } - - throw new Error(`Unexpected path ${pathName}`); - }), - }; -} - -function createResolveBranch(role: "preview" | "production" = "preview") { - return vi.fn().mockImplementation((_projectId: string, options: { branchName: string }) => Promise.resolve({ - id: `branch_${options.branchName.replace(/[^a-z0-9]+/gi, "_")}`, - name: options.branchName, - role, - })); -} - describe("app deploy branch database setup", () => { it("deploy --db creates a branch database, applies schema, and writes branch env overrides before deploying", async () => { const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); @@ -131,6 +64,7 @@ describe("app deploy branch database setup", () => { isManagedBySystem: false, })); const listEnvironmentVariables = vi.fn().mockResolvedValue([]); + const updateEnvironmentVariable = vi.fn(); const deployApp = vi.fn().mockResolvedValue({ projectId: "proj_123", app: { @@ -172,6 +106,7 @@ describe("app deploy branch database setup", () => { createBranchDatabase, listEnvironmentVariables, createEnvironmentVariable, + updateEnvironmentVariable, deployApp, listDeployments: vi.fn(), showDeployment: vi.fn(), @@ -264,6 +199,7 @@ describe("app deploy branch database setup", () => { ]); const createBranchDatabase = vi.fn(); const createEnvironmentVariable = vi.fn(); + const updateEnvironmentVariable = vi.fn(); const deployApp = vi.fn().mockResolvedValue({ projectId: "proj_123", app: { @@ -288,6 +224,15 @@ describe("app deploy branch database setup", () => { isManagedBySystem: false, }]; } + if (options.key === "DIRECT_URL") { + return [{ + id: "env_direct_url", + key: "DIRECT_URL", + branchId, + className: "preview", + isManagedBySystem: false, + }]; + } return []; }); @@ -305,6 +250,7 @@ describe("app deploy branch database setup", () => { createBranchDatabase, listEnvironmentVariables, createEnvironmentVariable, + updateEnvironmentVariable, deployApp, listDeployments: vi.fn(), showDeployment: vi.fn(), @@ -337,15 +283,143 @@ describe("app deploy branch database setup", () => { expect(createBranchDatabase).not.toHaveBeenCalled(); expect(createEnvironmentVariable).not.toHaveBeenCalled(); + expect(updateEnvironmentVariable).not.toHaveBeenCalled(); expect(deployApp).toHaveBeenCalled(); expect(result.result.branchDatabase).toEqual({ status: "skipped", reason: "branch-env-exists", - envVars: ["DATABASE_URL"], + envVars: ["DATABASE_URL", "DIRECT_URL"], schema: null, }); }); + it("deploy --db repairs partial branch database env wiring", async () => { + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const branchId = "branch_feature_db"; + 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/db", + branchId, + databaseUrl: "postgres://pooled", + directUrl: "postgres://direct", + }); + const createEnvironmentVariable = vi.fn().mockResolvedValue({ + id: "env_direct_url", + key: "DIRECT_URL", + branchId, + className: "preview", + isManagedBySystem: false, + }); + const updateEnvironmentVariable = vi.fn().mockResolvedValue({ + id: "env_database_url", + key: "DATABASE_URL", + branchId, + className: "preview", + isManagedBySystem: false, + }); + const deployApp = vi.fn().mockResolvedValue({ + projectId: "proj_123", + app: { + id: "app_1", + name: "hello-world", + region: "eu-central-1", + liveDeploymentId: "dep_123", + }, + deployment: { + id: "dep_123", + status: "running", + url: "https://hello-world.prisma.app", + }, + }); + const listEnvironmentVariables = vi.fn().mockImplementation(async (options: { key?: string }) => { + if (options.key === "DATABASE_URL") { + return [{ + id: "env_database_url", + key: "DATABASE_URL", + branchId, + className: "preview", + isManagedBySystem: false, + }]; + } + return []; + }); + + vi.doMock("../src/lib/auth/guard", () => ({ + requireComputeAuth, + })); + vi.doMock("../src/lib/app/branch-database", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + runBranchDatabaseSchemaSetup: vi.fn().mockResolvedValue({ + command: "db-push", + schemaPath: "prisma/schema.prisma", + }), + }; + }); + vi.doMock("../src/lib/app/preview-provider", () => ({ + createPreviewAppProvider: vi.fn(() => ({ + resolveBranch: vi.fn().mockResolvedValue({ + id: branchId, + name: "feature/db", + role: "preview", + }), + listApps, + createBranchDatabase, + listEnvironmentVariables, + createEnvironmentVariable, + updateEnvironmentVariable, + 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 mkdir(path.join(cwd, "prisma"), { recursive: true }); + await writeFile(path.join(cwd, "prisma/schema.prisma"), "datasource db { provider = \"postgresql\" url = env(\"DATABASE_URL\") }\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/db", + framework: "hono", + db: true, + }); + + expect(createBranchDatabase).toHaveBeenCalled(); + expect(updateEnvironmentVariable).toHaveBeenCalledWith({ + envVarId: "env_database_url", + value: "postgres://pooled", + signal: context.runtime.signal, + }); + expect(createEnvironmentVariable).toHaveBeenCalledWith( + expect.objectContaining({ + key: "DIRECT_URL", + value: "postgres://direct", + }), + ); + expect(result.result.branchDatabase).toMatchObject({ + status: "created", + envVars: ["DATABASE_URL", "DIRECT_URL"], + }); + }); + it("prompts for branch database setup when a preview deploy appears to use a database", async () => { const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); const branchId = "branch_feature_db"; @@ -367,6 +441,7 @@ describe("app deploy branch database setup", () => { className: "preview", isManagedBySystem: false, }); + const updateEnvironmentVariable = vi.fn(); const deployApp = vi.fn().mockResolvedValue({ projectId: "proj_123", app: { @@ -413,6 +488,7 @@ describe("app deploy branch database setup", () => { createBranchDatabase, listEnvironmentVariables: vi.fn().mockResolvedValue([]), createEnvironmentVariable, + updateEnvironmentVariable, deployApp, listDeployments: vi.fn(), showDeployment: vi.fn(), @@ -466,6 +542,7 @@ describe("app deploy branch database setup", () => { createBranchDatabase, listEnvironmentVariables: vi.fn().mockResolvedValue([]), createEnvironmentVariable: vi.fn(), + updateEnvironmentVariable: vi.fn(), deployApp, listDeployments: vi.fn(), showDeployment: vi.fn(), @@ -501,4 +578,96 @@ describe("app deploy branch database setup", () => { expect(createBranchDatabase).not.toHaveBeenCalled(); expect(deployApp).not.toHaveBeenCalled(); }); + + it("stops deploy when branch database schema setup fails", async () => { + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const branchId = "branch_feature_db"; + 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/db", + branchId, + databaseUrl: "postgres://pooled", + directUrl: "postgres://direct", + }); + const createEnvironmentVariable = vi.fn(); + const updateEnvironmentVariable = vi.fn(); + const deployApp = vi.fn(); + const runBranchDatabaseSchemaSetup = vi.fn().mockRejectedValue(new Error("Migration failed")); + + 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/db", + role: "preview", + }), + listApps, + createBranchDatabase, + listEnvironmentVariables: vi.fn().mockResolvedValue([]), + createEnvironmentVariable, + updateEnvironmentVariable, + 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 mkdir(path.join(cwd, "prisma"), { recursive: true }); + await writeFile(path.join(cwd, "prisma/schema.prisma"), "datasource db { provider = \"postgresql\" url = env(\"DATABASE_URL\") }\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: "SCHEMA_SETUP_FAILED", + domain: "app", + }); + expect(createBranchDatabase).toHaveBeenCalled(); + expect(createEnvironmentVariable).not.toHaveBeenCalled(); + expect(updateEnvironmentVariable).not.toHaveBeenCalled(); + expect(deployApp).not.toHaveBeenCalled(); + }); + + it("chooses a deterministic schema.prisma when multiple schemas 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, "packages/a/prisma"), { recursive: true }); + await mkdir(path.join(cwd, "prisma"), { recursive: true }); + await writeFile(path.join(cwd, "packages/a/prisma/schema.prisma"), ""); + await writeFile(path.join(cwd, "prisma/schema.prisma"), ""); + + const signal = await inspectBranchDatabaseSignal(cwd, new AbortController().signal); + + expect(signal.schema?.path).toBe(path.join(cwd, "prisma/schema.prisma")); + }); }); diff --git a/packages/cli/tests/app-controller.test.ts b/packages/cli/tests/app-controller.test.ts index 1762f2a..474da1d 100644 --- a/packages/cli/tests/app-controller.test.ts +++ b/packages/cli/tests/app-controller.test.ts @@ -3,6 +3,8 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createProjectClient, createResolveBranch } from "./helpers/mock-factories"; + beforeEach(() => { process.env.PRISMA_CLI_TEST_REMEMBER_PROJECT_ID = "proj_123"; process.env.PRISMA_CLI_TEST_REMEMBER_PROJECT_NAME = "Acme Dashboard"; @@ -40,80 +42,12 @@ afterEach(() => { vi.restoreAllMocks(); }); -function createProjectClient( - projectId = "proj_123", - options: { - branchExists?: boolean; - isDefault?: boolean; - } = {}, -) { - const branchRecord = (branchName: string) => ({ - id: `branch_${branchName.replace(/[^a-z0-9]+/gi, "_")}`, - gitName: branchName, - isDefault: options.isDefault ?? branchName === "main", - role: "preview", - }); - - return { - token: "token", - GET: vi.fn().mockImplementation((pathName: string, request?: { params?: { query?: { gitName?: string } } }) => { - if (pathName === "/v1/projects") { - return { - data: { - data: [ - { - id: projectId, - name: projectId === "proj_456" ? "Billing API" : "Acme Dashboard", - slug: projectId === "proj_456" ? "billing-api" : "acme-dashboard", - workspace: { - id: "ws_123", - name: "Acme Inc", - }, - }, - ], - }, - }; - } - - if (pathName === "/v1/projects/{projectId}/branches") { - const branchName = request?.params?.query?.gitName ?? "main"; - return { - data: { - data: options.branchExists === false ? [] : [branchRecord(branchName)], - }, - }; - } - - throw new Error(`Unexpected path ${pathName}`); - }), - POST: vi.fn().mockImplementation((pathName: string, request?: { body?: { gitName?: string } }) => { - if (pathName === "/v1/projects/{projectId}/branches") { - const branchName = request?.body?.gitName ?? "main"; - return { - data: { - data: branchRecord(branchName), - }, - }; - } - - throw new Error(`Unexpected path ${pathName}`); - }), - }; -} - -function createResolveBranch(role: "preview" | "production" = "preview") { - return vi.fn().mockImplementation((_projectId: string, options: { branchName: string }) => Promise.resolve({ - id: `branch_${options.branchName.replace(/[^a-z0-9]+/gi, "_")}`, - name: options.branchName, - role, - })); -} - function withBranchDatabaseProviderDefaults>(provider: T) { return { createBranchDatabase: vi.fn(), listEnvironmentVariables: vi.fn().mockResolvedValue([]), createEnvironmentVariable: vi.fn(), + updateEnvironmentVariable: vi.fn(), ...provider, }; } diff --git a/packages/cli/tests/app.test.ts b/packages/cli/tests/app.test.ts index 197bdbb..5ad6623 100644 --- a/packages/cli/tests/app.test.ts +++ b/packages/cli/tests/app.test.ts @@ -239,4 +239,19 @@ describe("app commands", () => { expect(listEnv.exitCode).not.toBe(0); expect(listEnv.stderr).toContain("unknown command"); }); + + it("rejects mutually exclusive deploy database flags", async () => { + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + + const result = await executeCli({ + argv: ["app", "deploy", "--db", "--no-db", "--yes"], + cwd, + stateDir, + fixturePath, + }); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain("app deploy accepts either --db or --no-db"); + }); }); diff --git a/packages/cli/tests/helpers/mock-factories.ts b/packages/cli/tests/helpers/mock-factories.ts new file mode 100644 index 0000000..a49ac9c --- /dev/null +++ b/packages/cli/tests/helpers/mock-factories.ts @@ -0,0 +1,70 @@ +import { vi } from "vitest"; + +export function createProjectClient( + projectId = "proj_123", + options: { + branchExists?: boolean; + isDefault?: boolean; + } = {}, +) { + const branchRecord = (branchName: string) => ({ + id: `branch_${branchName.replace(/[^a-z0-9]+/gi, "_")}`, + gitName: branchName, + isDefault: options.isDefault ?? branchName === "main", + role: "preview", + }); + + return { + token: "token", + GET: vi.fn().mockImplementation((pathName: string, request?: { params?: { query?: { gitName?: string } } }) => { + if (pathName === "/v1/projects") { + return { + data: { + data: [ + { + id: projectId, + name: projectId === "proj_456" ? "Billing API" : "Acme Dashboard", + slug: projectId === "proj_456" ? "billing-api" : "acme-dashboard", + workspace: { + id: "ws_123", + name: "Acme Inc", + }, + }, + ], + }, + }; + } + + if (pathName === "/v1/projects/{projectId}/branches") { + const branchName = request?.params?.query?.gitName ?? "main"; + return { + data: { + data: options.branchExists === false ? [] : [branchRecord(branchName)], + }, + }; + } + + throw new Error(`Unexpected path ${pathName}`); + }), + POST: vi.fn().mockImplementation((pathName: string, request?: { body?: { gitName?: string } }) => { + if (pathName === "/v1/projects/{projectId}/branches") { + const branchName = request?.body?.gitName ?? "main"; + return { + data: { + data: branchRecord(branchName), + }, + }; + } + + throw new Error(`Unexpected path ${pathName}`); + }), + }; +} + +export function createResolveBranch(role: "preview" | "production" = "preview") { + return vi.fn().mockImplementation((_projectId: string, options: { branchName: string }) => Promise.resolve({ + id: `branch_${options.branchName.replace(/[^a-z0-9]+/gi, "_")}`, + name: options.branchName, + role, + })); +} From 2ecd004d52718f8fba922bdbc70c5ec18d6269f2 Mon Sep 17 00:00:00 2001 From: Luan van der Westhuizen Date: Thu, 4 Jun 2026 08:06:19 +0200 Subject: [PATCH 3/4] fix: address branch database rereview feedback --- docs/product/error-conventions.md | 2 - packages/cli/src/controllers/app.ts | 30 +---- .../cli/src/lib/app/branch-database-deploy.ts | 7 + .../src/lib/app/preview-branch-database.ts | 19 +++ packages/cli/src/lib/app/preview-provider.ts | 12 +- .../cli/tests/app-branch-database.test.ts | 122 ++++++++++++++++++ packages/cli/tests/app-controller.test.ts | 1 + 7 files changed, 157 insertions(+), 36 deletions(-) diff --git a/docs/product/error-conventions.md b/docs/product/error-conventions.md index e9a7b4f..0d56877 100644 --- a/docs/product/error-conventions.md +++ b/docs/product/error-conventions.md @@ -193,7 +193,6 @@ These codes are the minimum stable set for the MVP: - `REPO_ALREADY_CONNECTED` - `REPO_CONNECTION_FAILED` - `BUILD_FAILED` -- `BRANCH_RESOLUTION_FAILED` - `BRANCH_DATABASE_SETUP_FAILED` - `SCHEMA_SETUP_FAILED` - `RUN_FAILED` @@ -238,7 +237,6 @@ Recommended meanings: - `REPO_ALREADY_CONNECTED`: a project already has a different GitHub repository connected - `REPO_CONNECTION_FAILED`: the Management API repository connection operation failed - `BUILD_FAILED`: build failed before a healthy deployment existed -- `BRANCH_RESOLUTION_FAILED`: the CLI could not inspect the selected branch before running an app command - `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 - `RUN_FAILED`: local framework run command could not be started or exited unsuccessfully diff --git a/packages/cli/src/controllers/app.ts b/packages/cli/src/controllers/app.ts index 2b73edf..bd158d7 100644 --- a/packages/cli/src/controllers/app.ts +++ b/packages/cli/src/controllers/app.ts @@ -2291,7 +2291,7 @@ async function resolveProjectContext( return { ...resolved, branch: { - id: await resolveExistingAppBranchId(client, resolved.project.id, branch.name, context.runtime.signal), + id: null, name: branch.name, kind: toBranchKind(branch.name), }, @@ -2491,34 +2491,6 @@ function toBranchKind(name: string): BranchKind { return name === "production" || name === "main" ? "production" : "preview"; } -async function resolveExistingAppBranchId( - client: ManagementApiClient, - projectId: string, - branchName: string, - signal: AbortSignal, -): Promise { - const result = await client.GET("/v1/projects/{projectId}/branches", { - params: { - path: { projectId }, - query: { gitName: branchName }, - }, - signal, - }); - if (result.error || !result.data) { - throw new CliError({ - code: "BRANCH_RESOLUTION_FAILED", - domain: "app", - summary: `Failed to resolve branch "${branchName}"`, - why: result.error instanceof Error ? result.error.message : `Management API returned HTTP ${result.response.status}.`, - fix: "Retry the command, or pass --branch with an existing Git branch name.", - exitCode: 1, - nextSteps: [`prisma-cli app deploy --branch ${formatCommandArgument(branchName)}`], - }); - } - - return result.data.data[0]?.id ?? null; -} - function toResultBranch(branch: ResolvedAppProjectContext["branch"]): AppDeployResult["branch"] { return { name: branch.name, diff --git a/packages/cli/src/lib/app/branch-database-deploy.ts b/packages/cli/src/lib/app/branch-database-deploy.ts index c83c24b..801fb52 100644 --- a/packages/cli/src/lib/app/branch-database-deploy.ts +++ b/packages/cli/src/lib/app/branch-database-deploy.ts @@ -242,6 +242,13 @@ async function upsertBranchDatabaseEnvVars( branchName: branch.name, }); written.push("DIRECT_URL"); + } else if (envState.branchDirectUrl) { + await provider.deleteEnvironmentVariable({ + envVarId: envState.branchDirectUrl.id, + signal: context.runtime.signal, + }).catch((error) => { + throw branchDatabaseSetupFailedError("Failed to remove stale DIRECT_URL", error, branch.name); + }); } return written; diff --git a/packages/cli/src/lib/app/preview-branch-database.ts b/packages/cli/src/lib/app/preview-branch-database.ts index 2d96f0b..9a1ffae 100644 --- a/packages/cli/src/lib/app/preview-branch-database.ts +++ b/packages/cli/src/lib/app/preview-branch-database.ts @@ -171,6 +171,25 @@ export async function updateEnvironmentVariable( return normalizeEnvironmentVariable(result.data.data as RawEnvironmentVariableRecord); } +export async function deleteEnvironmentVariable( + client: ManagementApiClient, + options: { + envVarId: string; + signal?: AbortSignal; + }, +): Promise { + const result = await client.DELETE("/v1/environment-variables/{envVarId}", { + params: { + path: { envVarId: options.envVarId }, + }, + signal: options.signal, + }); + + if (result.error) { + throw apiCallError("Failed to delete environment variable", result.response, result.error); + } +} + function normalizeEnvironmentVariable(variable: RawEnvironmentVariableRecord): PreviewEnvironmentVariableRecord { return { id: variable.id, diff --git a/packages/cli/src/lib/app/preview-provider.ts b/packages/cli/src/lib/app/preview-provider.ts index 2b8921a..6701952 100644 --- a/packages/cli/src/lib/app/preview-provider.ts +++ b/packages/cli/src/lib/app/preview-provider.ts @@ -10,6 +10,7 @@ import type { PreviewBuildType } from "./preview-build"; import type { BranchKind } from "../../types/branch"; import { createBranchDatabase, + deleteEnvironmentVariable, createEnvironmentVariable, listEnvironmentVariables, updateEnvironmentVariable, @@ -144,11 +145,8 @@ export interface PreviewAppProvider { value: string; signal?: AbortSignal; }): Promise; - updateEnvironmentVariable(options: { - envVarId: string; - value: string; - signal?: AbortSignal; - }): Promise; + updateEnvironmentVariable(options: { envVarId: string; value: string; signal?: AbortSignal }): Promise; + deleteEnvironmentVariable(options: { envVarId: string; signal?: AbortSignal }): Promise; listApps(projectId: string, options?: { branchName?: string; signal?: AbortSignal }): Promise; removeApp(appId: string, options?: { signal?: AbortSignal }): Promise; listDomains(appId: string, options?: { signal?: AbortSignal }): Promise; @@ -261,6 +259,10 @@ export function createPreviewAppProvider( return updateEnvironmentVariable(client, options); }, + async deleteEnvironmentVariable(options) { + return deleteEnvironmentVariable(client, options); + }, + async removeApp(appId, options) { const appResult = await sdk.showService({ serviceId: appId, signal: options?.signal }); if (appResult.isErr()) { diff --git a/packages/cli/tests/app-branch-database.test.ts b/packages/cli/tests/app-branch-database.test.ts index 7c62bc4..a9ac2a1 100644 --- a/packages/cli/tests/app-branch-database.test.ts +++ b/packages/cli/tests/app-branch-database.test.ts @@ -420,6 +420,128 @@ describe("app deploy branch database setup", () => { }); }); + it("deploy --db removes stale DIRECT_URL when the new branch database has no direct URL", async () => { + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const branchId = "branch_feature_db"; + 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/db", + branchId, + databaseUrl: "postgres://pooled", + directUrl: null, + }); + const createEnvironmentVariable = vi.fn().mockResolvedValue({ + id: "env_database_url", + key: "DATABASE_URL", + branchId, + className: "preview", + isManagedBySystem: false, + }); + const updateEnvironmentVariable = vi.fn(); + const deleteEnvironmentVariable = vi.fn().mockResolvedValue(undefined); + const deployApp = vi.fn().mockResolvedValue({ + projectId: "proj_123", + app: { + id: "app_1", + name: "hello-world", + region: "eu-central-1", + liveDeploymentId: "dep_123", + }, + deployment: { + id: "dep_123", + status: "running", + url: "https://hello-world.prisma.app", + }, + }); + const listEnvironmentVariables = vi.fn().mockImplementation(async (options: { key?: string }) => { + if (options.key === "DIRECT_URL") { + return [{ + id: "env_direct_url", + key: "DIRECT_URL", + branchId, + className: "preview", + isManagedBySystem: false, + }]; + } + return []; + }); + + vi.doMock("../src/lib/auth/guard", () => ({ + requireComputeAuth, + })); + vi.doMock("../src/lib/app/branch-database", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + runBranchDatabaseSchemaSetup: vi.fn().mockResolvedValue({ + command: "db-push", + schemaPath: "prisma/schema.prisma", + }), + }; + }); + vi.doMock("../src/lib/app/preview-provider", () => ({ + createPreviewAppProvider: vi.fn(() => ({ + resolveBranch: vi.fn().mockResolvedValue({ + id: branchId, + name: "feature/db", + role: "preview", + }), + listApps, + createBranchDatabase, + listEnvironmentVariables, + createEnvironmentVariable, + updateEnvironmentVariable, + deleteEnvironmentVariable, + 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 mkdir(path.join(cwd, "prisma"), { recursive: true }); + await writeFile(path.join(cwd, "prisma/schema.prisma"), "datasource db { provider = \"postgresql\" url = env(\"DATABASE_URL\") }\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/db", + framework: "hono", + db: true, + }); + + expect(createEnvironmentVariable).toHaveBeenCalledWith( + expect.objectContaining({ + key: "DATABASE_URL", + value: "postgres://pooled", + }), + ); + expect(updateEnvironmentVariable).not.toHaveBeenCalled(); + expect(deleteEnvironmentVariable).toHaveBeenCalledWith({ + envVarId: "env_direct_url", + signal: context.runtime.signal, + }); + expect(result.result.branchDatabase).toMatchObject({ + status: "created", + envVars: ["DATABASE_URL"], + }); + }); + it("prompts for branch database setup when a preview deploy appears to use a database", async () => { const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); const branchId = "branch_feature_db"; diff --git a/packages/cli/tests/app-controller.test.ts b/packages/cli/tests/app-controller.test.ts index 474da1d..53bdd6c 100644 --- a/packages/cli/tests/app-controller.test.ts +++ b/packages/cli/tests/app-controller.test.ts @@ -48,6 +48,7 @@ function withBranchDatabaseProviderDefaults>(p listEnvironmentVariables: vi.fn().mockResolvedValue([]), createEnvironmentVariable: vi.fn(), updateEnvironmentVariable: vi.fn(), + deleteEnvironmentVariable: vi.fn(), ...provider, }; } From a7095d3aff3db8e334cdc89a688c93b296cf246f Mon Sep 17 00:00:00 2001 From: Luan van der Westhuizen Date: Thu, 4 Jun 2026 09:05:52 +0200 Subject: [PATCH 4/4] fix: harden branch database deploy setup --- docs/product/command-spec.md | 5 +- .../cli/src/lib/app/branch-database-deploy.ts | 155 ++++++++++++------ .../src/lib/app/preview-branch-database.ts | 19 +++ packages/cli/src/lib/app/preview-provider.ts | 6 + .../cli/tests/app-branch-database.test.ts | 132 ++++++++++++--- packages/cli/tests/app-controller.test.ts | 1 + 6 files changed, 243 insertions(+), 75 deletions(-) diff --git a/docs/product/command-spec.md b/docs/product/command-spec.md index f7982b9..fc1410a 100644 --- a/docs/product/command-spec.md +++ b/docs/product/command-spec.md @@ -634,8 +634,9 @@ Behavior: - `--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 - branch database setup only runs for preview Branches; production database env vars are managed with `project env` -- branch database setup never overwrites fully wired branch database env vars; when the branch already has both `DATABASE_URL` and `DIRECT_URL`, `--db` leaves them unchanged and continues -- when only one branch database env var exists, explicit `--db` treats it as partial setup and repairs the pair by writing fresh branch database env values +- branch database setup never overwrites an existing branch-scoped `DATABASE_URL`; when the branch already has `DATABASE_URL`, `--db` leaves branch database env vars unchanged and continues +- 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 diff --git a/packages/cli/src/lib/app/branch-database-deploy.ts b/packages/cli/src/lib/app/branch-database-deploy.ts index 801fb52..ebf49d1 100644 --- a/packages/cli/src/lib/app/branch-database-deploy.ts +++ b/packages/cli/src/lib/app/branch-database-deploy.ts @@ -92,7 +92,7 @@ export async function maybeSetupBranchDatabase( .map((variable) => variable.key) .sort(); - if (hasCompleteBranchDatabaseEnv(envState)) { + if (envState.branchDatabaseUrl) { const warning = options.db === true ? `Branch "${branch.name}" already has DATABASE_URL. Leaving branch database env vars unchanged.` : null; @@ -113,10 +113,6 @@ export async function maybeSetupBranchDatabase( }; } - if (options.db !== true && envState.branchDatabaseUrl) { - return emptyBranchDatabaseSetupOutcome(); - } - const hasSignal = hasBranchDatabaseSignal(localSignal) || Boolean(envState.previewDatabaseUrl); if (options.db !== true) { if (!hasSignal) { @@ -167,48 +163,52 @@ async function setupBranchDatabase( }); emitBranchDatabaseProgress(context, "success", "Created branch database"); - let schemaSetup: BranchDatabaseSchemaSetupResult | null = null; - const warnings: string[] = []; - let skippedSchemaWarning: string | null = null; - if (signal.schema) { - emitBranchDatabaseProgress(context, "pending", `Applying database schema with ${formatSchemaSetupCommand(signal.schema.command)}`); - schemaSetup = await runBranchDatabaseSchemaSetup({ - context, - schema: signal.schema, - databaseUrl: database.databaseUrl, - directUrl: database.directUrl, - }).catch((error) => { - throw schemaSetupFailedError(error, signal.schema!, branch.name); - }); - 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."; - } + try { + let schemaSetup: BranchDatabaseSchemaSetupResult | null = null; + const warnings: string[] = []; + let skippedSchemaWarning: string | null = null; + if (signal.schema) { + emitBranchDatabaseProgress(context, "pending", `Applying database schema with ${formatSchemaSetupCommand(signal.schema.command)}`); + schemaSetup = await runBranchDatabaseSchemaSetup({ + context, + schema: signal.schema, + databaseUrl: database.databaseUrl, + directUrl: database.directUrl, + }).catch((error) => { + throw schemaSetupFailedError(error, signal.schema!, branch.name); + }); + 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."; + } - const envVars = await upsertBranchDatabaseEnvVars(context, provider, projectId, branch, database, envState); - emitBranchDatabaseProgress(context, "success", `Added branch env override${envVars.length === 1 ? "" : "s"} ${envVars.join(", ")}`); - if (skippedSchemaWarning) { - emitBranchDatabaseWarning(context, skippedSchemaWarning); - warnings.push(skippedSchemaWarning); - } + const envVars = await upsertBranchDatabaseEnvVars(context, provider, projectId, branch, database, envState); + emitBranchDatabaseProgress(context, "success", `Added branch env override${envVars.length === 1 ? "" : "s"} ${envVars.join(", ")}`); + if (skippedSchemaWarning) { + emitBranchDatabaseWarning(context, skippedSchemaWarning); + warnings.push(skippedSchemaWarning); + } - return { - result: { - status: "created", - database: { - id: database.id, - name: database.name, + return { + result: { + status: "created", + database: { + id: database.id, + name: database.name, + }, + envVars, + schema: schemaSetup + ? { + command: schemaSetup.command, + path: schemaSetup.schemaPath, + } + : null, }, - envVars, - schema: schemaSetup - ? { - command: schemaSetup.command, - path: schemaSetup.schemaPath, - } - : null, - }, - warnings, - }; + warnings, + }; + } catch (error) { + throw await cleanupCreatedBranchDatabaseAfterFailure(context, provider, database, branch.name, error); + } } async function upsertBranchDatabaseEnvVars( @@ -329,10 +329,6 @@ function hasInlineDatabaseEnvVars(envVars: Record | undefined): return Boolean(envVars && ("DATABASE_URL" in envVars || "DIRECT_URL" in envVars)); } -function hasCompleteBranchDatabaseEnv(envState: BranchDatabaseEnvState): boolean { - return Boolean(envState.branchDatabaseUrl && envState.branchDirectUrl); -} - function maybeRenderBranchDatabaseSignal( context: CommandContext, branchName: string, @@ -416,6 +412,59 @@ function branchDatabaseSetupFailedError(summary: string, error: unknown, branchN }); } +async function cleanupCreatedBranchDatabaseAfterFailure( + context: CommandContext, + provider: PreviewAppProvider, + database: PreviewBranchDatabaseRecord, + branchName: string, + error: unknown, +): Promise { + const setupError = error instanceof CliError + ? error + : branchDatabaseSetupFailedError("Branch database setup failed", error, branchName); + + emitBranchDatabaseProgress(context, "pending", "Removing branch database after setup failed"); + try { + await provider.deleteBranchDatabase({ + databaseId: database.id, + signal: context.runtime.signal, + }); + emitBranchDatabaseProgress(context, "success", "Removed branch database after setup failed"); + } catch (cleanupError) { + return branchDatabaseCleanupFailedError(setupError, cleanupError, database, branchName); + } + + return setupError; +} + +function branchDatabaseCleanupFailedError( + setupError: CliError, + cleanupError: unknown, + database: PreviewBranchDatabaseRecord, + branchName: string, +): CliError { + const cleanupWhy = cleanupError instanceof Error ? cleanupError.message : String(cleanupError); + const setupWhy = setupError.why ?? "Branch database setup failed."; + + return new CliError({ + code: setupError.code, + domain: setupError.domain, + summary: setupError.summary, + why: `${setupWhy} Prisma could not delete the created database "${database.name}" (${database.id}): ${cleanupWhy}`, + fix: "Delete the created branch database from Console or contact Prisma support, then rerun deploy with --db.", + debug: formatCombinedDebugDetails(setupError, cleanupError), + meta: { + ...setupError.meta, + branch: branchName, + databaseId: database.id, + databaseName: database.name, + cleanupFailed: true, + }, + exitCode: setupError.exitCode, + nextSteps: [], + }); +} + function schemaSetupFailedError( error: unknown, schema: NonNullable, @@ -450,3 +499,13 @@ function formatDebugDetails(error: unknown): string | null { return typeof error === "string" ? error : null; } + +function formatCombinedDebugDetails(setupError: CliError, cleanupError: unknown): string | null { + const setupDebug = setupError.debug ?? setupError.stack ?? setupError.message; + const cleanupDebug = formatDebugDetails(cleanupError); + + return [ + setupDebug ? `Setup error:\n${setupDebug}` : null, + cleanupDebug ? `Cleanup error:\n${cleanupDebug}` : null, + ].filter((line): line is string => Boolean(line)).join("\n\n") || null; +} diff --git a/packages/cli/src/lib/app/preview-branch-database.ts b/packages/cli/src/lib/app/preview-branch-database.ts index 9a1ffae..db8c377 100644 --- a/packages/cli/src/lib/app/preview-branch-database.ts +++ b/packages/cli/src/lib/app/preview-branch-database.ts @@ -146,6 +146,25 @@ export async function createEnvironmentVariable( return normalizeEnvironmentVariable(result.data.data as RawEnvironmentVariableRecord); } +export async function deleteBranchDatabase( + client: ManagementApiClient, + options: { + databaseId: string; + signal?: AbortSignal; + }, +): Promise { + const result = await client.DELETE("/v1/databases/{databaseId}", { + params: { + path: { databaseId: options.databaseId }, + }, + signal: options.signal, + }); + + if (result.error) { + throw apiCallError("Failed to delete branch database", result.response, result.error); + } +} + export async function updateEnvironmentVariable( client: ManagementApiClient, options: { diff --git a/packages/cli/src/lib/app/preview-provider.ts b/packages/cli/src/lib/app/preview-provider.ts index 6701952..6e1ecbb 100644 --- a/packages/cli/src/lib/app/preview-provider.ts +++ b/packages/cli/src/lib/app/preview-provider.ts @@ -10,6 +10,7 @@ import type { PreviewBuildType } from "./preview-build"; import type { BranchKind } from "../../types/branch"; import { createBranchDatabase, + deleteBranchDatabase, deleteEnvironmentVariable, createEnvironmentVariable, listEnvironmentVariables, @@ -130,6 +131,7 @@ export interface PreviewAppProvider { branchName: string; signal?: AbortSignal; }): Promise; + deleteBranchDatabase(options: { databaseId: string; signal?: AbortSignal }): Promise; listEnvironmentVariables(options: { projectId: string; className?: "production" | "preview"; @@ -247,6 +249,10 @@ export function createPreviewAppProvider( return createBranchDatabase(client, options); }, + async deleteBranchDatabase(options) { + return deleteBranchDatabase(client, options); + }, + async listEnvironmentVariables(options) { return listEnvironmentVariables(client, options); }, diff --git a/packages/cli/tests/app-branch-database.test.ts b/packages/cli/tests/app-branch-database.test.ts index a9ac2a1..3a63598 100644 --- a/packages/cli/tests/app-branch-database.test.ts +++ b/packages/cli/tests/app-branch-database.test.ts @@ -200,6 +200,7 @@ describe("app deploy branch database setup", () => { const createBranchDatabase = vi.fn(); const createEnvironmentVariable = vi.fn(); const updateEnvironmentVariable = vi.fn(); + const deleteBranchDatabase = vi.fn(); const deployApp = vi.fn().mockResolvedValue({ projectId: "proj_123", app: { @@ -224,15 +225,6 @@ describe("app deploy branch database setup", () => { isManagedBySystem: false, }]; } - if (options.key === "DIRECT_URL") { - return [{ - id: "env_direct_url", - key: "DIRECT_URL", - branchId, - className: "preview", - isManagedBySystem: false, - }]; - } return []; }); @@ -248,6 +240,7 @@ describe("app deploy branch database setup", () => { }), listApps, createBranchDatabase, + deleteBranchDatabase, listEnvironmentVariables, createEnvironmentVariable, updateEnvironmentVariable, @@ -284,16 +277,17 @@ describe("app deploy branch database setup", () => { expect(createBranchDatabase).not.toHaveBeenCalled(); expect(createEnvironmentVariable).not.toHaveBeenCalled(); expect(updateEnvironmentVariable).not.toHaveBeenCalled(); + expect(deleteBranchDatabase).not.toHaveBeenCalled(); expect(deployApp).toHaveBeenCalled(); expect(result.result.branchDatabase).toEqual({ status: "skipped", reason: "branch-env-exists", - envVars: ["DATABASE_URL", "DIRECT_URL"], + envVars: ["DATABASE_URL"], schema: null, }); }); - it("deploy --db repairs partial branch database env wiring", async () => { + it("deploy --db repairs a branch that only has DIRECT_URL", async () => { const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); const branchId = "branch_feature_db"; const listApps = vi.fn().mockResolvedValue([ @@ -307,15 +301,15 @@ describe("app deploy branch database setup", () => { directUrl: "postgres://direct", }); const createEnvironmentVariable = vi.fn().mockResolvedValue({ - id: "env_direct_url", - key: "DIRECT_URL", + id: "env_database_url", + key: "DATABASE_URL", branchId, className: "preview", isManagedBySystem: false, }); const updateEnvironmentVariable = vi.fn().mockResolvedValue({ - id: "env_database_url", - key: "DATABASE_URL", + id: "env_direct_url", + key: "DIRECT_URL", branchId, className: "preview", isManagedBySystem: false, @@ -335,10 +329,10 @@ describe("app deploy branch database setup", () => { }, }); const listEnvironmentVariables = vi.fn().mockImplementation(async (options: { key?: string }) => { - if (options.key === "DATABASE_URL") { + if (options.key === "DIRECT_URL") { return [{ - id: "env_database_url", - key: "DATABASE_URL", + id: "env_direct_url", + key: "DIRECT_URL", branchId, className: "preview", isManagedBySystem: false, @@ -403,17 +397,17 @@ describe("app deploy branch database setup", () => { }); expect(createBranchDatabase).toHaveBeenCalled(); - expect(updateEnvironmentVariable).toHaveBeenCalledWith({ - envVarId: "env_database_url", - value: "postgres://pooled", - signal: context.runtime.signal, - }); expect(createEnvironmentVariable).toHaveBeenCalledWith( expect.objectContaining({ - key: "DIRECT_URL", - value: "postgres://direct", + key: "DATABASE_URL", + value: "postgres://pooled", }), ); + expect(updateEnvironmentVariable).toHaveBeenCalledWith({ + envVarId: "env_direct_url", + value: "postgres://direct", + signal: context.runtime.signal, + }); expect(result.result.branchDatabase).toMatchObject({ status: "created", envVars: ["DATABASE_URL", "DIRECT_URL"], @@ -714,6 +708,7 @@ describe("app deploy branch database setup", () => { databaseUrl: "postgres://pooled", directUrl: "postgres://direct", }); + const deleteBranchDatabase = vi.fn().mockResolvedValue(undefined); const createEnvironmentVariable = vi.fn(); const updateEnvironmentVariable = vi.fn(); const deployApp = vi.fn(); @@ -738,6 +733,7 @@ describe("app deploy branch database setup", () => { }), listApps, createBranchDatabase, + deleteBranchDatabase, listEnvironmentVariables: vi.fn().mockResolvedValue([]), createEnvironmentVariable, updateEnvironmentVariable, @@ -774,11 +770,97 @@ describe("app deploy branch database setup", () => { domain: "app", }); expect(createBranchDatabase).toHaveBeenCalled(); + expect(deleteBranchDatabase).toHaveBeenCalledWith({ + databaseId: "db_1", + signal: context.runtime.signal, + }); expect(createEnvironmentVariable).not.toHaveBeenCalled(); expect(updateEnvironmentVariable).not.toHaveBeenCalled(); expect(deployApp).not.toHaveBeenCalled(); }); + it("cleans up the created branch database when env wiring fails", async () => { + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const branchId = "branch_feature_db"; + 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/db", + branchId, + databaseUrl: "postgres://pooled", + directUrl: null, + }); + const deleteBranchDatabase = vi.fn().mockResolvedValue(undefined); + const createEnvironmentVariable = vi.fn().mockRejectedValue(new Error("env write failed")); + const deployApp = vi.fn(); + + vi.doMock("../src/lib/auth/guard", () => ({ + requireComputeAuth, + })); + vi.doMock("../src/lib/app/branch-database", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + runBranchDatabaseSchemaSetup: vi.fn().mockResolvedValue({ + command: "db-push", + schemaPath: "prisma/schema.prisma", + }), + }; + }); + vi.doMock("../src/lib/app/preview-provider", () => ({ + createPreviewAppProvider: vi.fn(() => ({ + resolveBranch: vi.fn().mockResolvedValue({ + id: branchId, + name: "feature/db", + role: "preview", + }), + listApps, + createBranchDatabase, + deleteBranchDatabase, + 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 mkdir(path.join(cwd, "prisma"), { recursive: true }); + await writeFile(path.join(cwd, "prisma/schema.prisma"), "datasource db { provider = \"postgresql\" url = env(\"DATABASE_URL\") }\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: "BRANCH_DATABASE_SETUP_FAILED", + domain: "app", + }); + expect(deleteBranchDatabase).toHaveBeenCalledWith({ + databaseId: "db_1", + signal: context.runtime.signal, + }); + expect(deployApp).not.toHaveBeenCalled(); + }); + it("chooses a deterministic schema.prisma when multiple schemas exist", async () => { const { createTempCwd } = await import("./helpers"); const { inspectBranchDatabaseSignal } = await import("../src/lib/app/branch-database"); diff --git a/packages/cli/tests/app-controller.test.ts b/packages/cli/tests/app-controller.test.ts index 53bdd6c..c9ad006 100644 --- a/packages/cli/tests/app-controller.test.ts +++ b/packages/cli/tests/app-controller.test.ts @@ -45,6 +45,7 @@ afterEach(() => { function withBranchDatabaseProviderDefaults>(provider: T) { return { createBranchDatabase: vi.fn(), + deleteBranchDatabase: vi.fn(), listEnvironmentVariables: vi.fn().mockResolvedValue([]), createEnvironmentVariable: vi.fn(), updateEnvironmentVariable: vi.fn(),