diff --git a/docs/product/command-spec.md b/docs/product/command-spec.md index eedba2c..78a1c51 100644 --- a/docs/product/command-spec.md +++ b/docs/product/command-spec.md @@ -631,15 +631,18 @@ 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 and dotenv file paths such as `--env .env` -- supports `--db` for preview Branches to create a new empty Prisma Postgres database, apply a supported local Prisma schema source when one exists, and write branch-scoped `DATABASE_URL` and `DIRECT_URL` overrides through the existing `project env` storage +- supports `--db` to create a new empty Prisma Postgres database, apply a supported local Prisma schema source when one exists, and write `DATABASE_URL` and `DIRECT_URL` 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 `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 +- preview Branch setup writes branch-scoped env-var overrides +- production setup writes production env vars only during the first production deploy, before the selected App has a live deployment +- later production deploys do not prompt for database setup; explicit `--db` is rejected once the selected production App has a live deployment +- 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 +- production setup treats existing production `DATABASE_URL` or `DIRECT_URL` as BYO DB intent; it does not prompt, and explicit `--db` leaves production env vars unchanged and continues with a warning +- when only `DIRECT_URL` exists on a preview branch, explicit `--db` treats it as partial setup and repairs the pair by writing fresh branch database env values +- if schema setup or env-var wiring fails after database creation, the CLI deletes the newly created database before returning the error +- database setup does not clone or infer schema from another database; it only creates an empty database and optionally applies schema from local code - Prisma Next config (`prisma-next.config.*`) is preferred over `schema.prisma`; setup runs `prisma-next contract emit` and then `prisma-next db init` - for Prisma ORM `schema.prisma`, setup runs `prisma migrate deploy` when `prisma/migrations` exists next to the schema, otherwise it runs `prisma db push` - when no supported Prisma schema source is found, `--db` still creates the database and env overrides but skips schema setup diff --git a/docs/product/error-conventions.md b/docs/product/error-conventions.md index 7f53a6d..ac1bb84 100644 --- a/docs/product/error-conventions.md +++ b/docs/product/error-conventions.md @@ -239,8 +239,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 source setup against a newly created Branch database failed before deployment started +- `BRANCH_DATABASE_SETUP_FAILED`: database creation or env-var wiring failed before deployment started +- `SCHEMA_SETUP_FAILED`: local Prisma schema source setup against a newly created 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 b7b0a48..61848dc 100644 --- a/docs/product/resource-model.md +++ b/docs/product/resource-model.md @@ -154,19 +154,20 @@ 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 a supported local -Prisma schema source when available, and write normal branch-scoped environment -variable overrides. +empty Prisma Postgres database, apply a supported local Prisma schema source +when available, and write normal environment variables for the deploy target. 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` +- preview Branch setup writes branch-scoped `DATABASE_URL` and `DIRECT_URL` overrides, not separate app bindings +- first production deploy setup writes production `DATABASE_URL` and `DIRECT_URL` env vars before the App has a live deployment +- database setup never overwrites an existing branch-scoped `DATABASE_URL` +- production setup treats existing production `DATABASE_URL` or `DIRECT_URL` as BYO DB intent and leaves env vars unchanged - schema setup is sourced only from local code; the CLI does not clone or infer schema from another database - Prisma Next config (`prisma-next.config.*`) is preferred over `schema.prisma` - known non-Postgres Prisma sources are treated as unsupported for automatic Prisma Postgres setup -- production database configuration is managed through explicit environment-variable commands +- later 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 c37c582..c7052fd 100644 --- a/packages/cli/src/commands/app/index.ts +++ b/packages/cli/src/commands/app/index.ts @@ -183,8 +183,8 @@ function createDeployCommand(runtime: CliRuntime): Command { new Option("--env ", "Environment variable assignment or dotenv file") .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("--db", "Create and wire a Prisma Postgres database for this deploy target")) + .addOption(new Option("--no-db", "Skip database setup")) .addOption(new Option("--prod", "Confirm intent to deploy to production")); addGlobalFlags(command); @@ -209,7 +209,7 @@ function createDeployCommand(runtime: CliRuntime): Command { if (hasDbConflict) { throw usageError( "app deploy accepts either --db or --no-db", - "--db requests branch database setup, while --no-db disables it.", + "--db requests database setup, while --no-db disables it.", "Pass exactly one database setup flag.", [ "prisma-cli app deploy --db", diff --git a/packages/cli/src/controllers/app.ts b/packages/cli/src/controllers/app.ts index 76eb7cc..47447b7 100644 --- a/packages/cli/src/controllers/app.ts +++ b/packages/cli/src/controllers/app.ts @@ -310,7 +310,7 @@ export async function runAppDeploy( framework = customized.framework; runtime = customized.runtime; - await enforceProductionDeployGate(context, provider, { + const productionDeployGate = await enforceProductionDeployGate(context, provider, { appId: selectedApp.appId, appName: selectedApp.displayName, branchKind: target.branch.kind, @@ -326,6 +326,7 @@ export async function runAppDeploy( const branchDatabaseSetup = await maybeSetupBranchDatabase(context, provider, projectId, toBranchDatabaseDeployBranch(target.branch), { db: options?.db, providedEnvVars: envVars, + firstProductionDeploy: productionDeployGate.firstProductionDeploy, }); const progressState = createPreviewDeployProgressState(); diff --git a/packages/cli/src/lib/app/branch-database-deploy.ts b/packages/cli/src/lib/app/branch-database-deploy.ts index 1dec731..6288762 100644 --- a/packages/cli/src/lib/app/branch-database-deploy.ts +++ b/packages/cli/src/lib/app/branch-database-deploy.ts @@ -34,9 +34,9 @@ export interface BranchDatabaseSetupOutcome { } interface BranchDatabaseEnvState { - branchDatabaseUrl: PreviewEnvironmentVariableRecord | null; - branchDirectUrl: PreviewEnvironmentVariableRecord | null; - previewDatabaseUrl: PreviewEnvironmentVariableRecord | null; + targetDatabaseUrl: PreviewEnvironmentVariableRecord | null; + targetDirectUrl: PreviewEnvironmentVariableRecord | null; + inheritedPreviewDatabaseUrl: PreviewEnvironmentVariableRecord | null; } export async function maybeSetupBranchDatabase( @@ -47,6 +47,7 @@ export async function maybeSetupBranchDatabase( options: { db: boolean | undefined; providedEnvVars: Record | undefined; + firstProductionDeploy: boolean; }, ): Promise { if (options.db === false) { @@ -56,9 +57,9 @@ export async function maybeSetupBranchDatabase( if (hasProvidedDatabaseEnvVars(options.providedEnvVars)) { if (options.db === true) { throw usageError( - "Branch database setup cannot be combined with provided database env vars", + "Database setup cannot be combined with provided database env vars", "The deploy command received --db and a DATABASE_URL or DIRECT_URL value from --env.", - "Remove the --env database value to let --db create a branch override, or remove --db to deploy with the provided value.", + "Remove the --env database value to let --db create and wire a database, or remove --db to deploy with the provided value.", [ "prisma-cli app deploy --db", "prisma-cli app deploy --env DATABASE_URL=postgresql://example", @@ -70,34 +71,19 @@ export async function maybeSetupBranchDatabase( return emptyBranchDatabaseSetupOutcome(); } - if (branch.kind === "production") { + if (branch.kind === "production" && !options.firstProductionDeploy) { 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", - ); + throw productionDatabaseSetupAfterFirstDeployError(); } 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(); + const envState = await inspectBranchDatabaseEnv(provider, projectId, branch, context.runtime.signal); + const targetEnvVars = getTargetDatabaseEnvVarKeys(envState); - if (envState.branchDatabaseUrl) { - const warning = options.db === true - ? `Branch "${branch.name}" already has DATABASE_URL. Leaving branch database env vars unchanged.` - : null; + if (hasExistingDatabaseEnvForTarget(branch, envState)) { + const warning = options.db === true ? existingDatabaseEnvWarning(branch, targetEnvVars) : null; if (warning) { emitBranchDatabaseWarning(context, warning); } @@ -106,8 +92,8 @@ export async function maybeSetupBranchDatabase( result: options.db === true ? { status: "skipped", - reason: "branch-env-exists", - envVars: branchEnvVars, + reason: existingDatabaseEnvReason(branch), + envVars: targetEnvVars, schema: null, } : undefined, @@ -115,22 +101,23 @@ export async function maybeSetupBranchDatabase( }; } + const localSignal = await inspectBranchDatabaseSignal(context.runtime.cwd, context.runtime.signal); if (localSignal.unsupportedSchema) { if (options.db === true) { - throw unsupportedBranchDatabaseSchemaError(localSignal.unsupportedSchema, branch.name, context); + throw unsupportedBranchDatabaseSchemaError(localSignal.unsupportedSchema, branch, context); } return emptyBranchDatabaseSetupOutcome(); } - const hasSignal = hasBranchDatabaseSignal(localSignal) || Boolean(envState.previewDatabaseUrl); + const hasSignal = hasBranchDatabaseSignal(localSignal) || Boolean(envState.inheritedPreviewDatabaseUrl); 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."; + const warning = databasePromptSuppressedWarning(branch); emitBranchDatabaseWarning(context, warning); return { result: undefined, @@ -138,17 +125,19 @@ export async function maybeSetupBranchDatabase( }; } - maybeRenderBranchDatabaseSignal(context, branch.name, localSignal, envState); + maybeRenderBranchDatabaseSignal(context, branch, localSignal, envState); const shouldCreate = await confirmPrompt({ input: context.runtime.stdin, output: context.output.stderr, - message: `Create an isolated database for branch "${branch.name}"?`, + message: databasePromptMessage(branch), initialValue: false, }); if (!shouldCreate) { return emptyBranchDatabaseSetupOutcome(); } + } else if (!canPrompt(context) && !context.flags.yes) { + throw nonInteractiveDatabaseSetupRequiresYesError(branch); } return setupBranchDatabase(context, provider, projectId, branch, localSignal, envState); @@ -162,16 +151,16 @@ async function setupBranchDatabase( signal: BranchDatabaseSignal, envState: BranchDatabaseEnvState, ): Promise { - emitBranchDatabaseProgress(context, "pending", "Creating branch database"); + emitBranchDatabaseProgress(context, "pending", "Creating 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); + throw branchDatabaseSetupFailedError("Failed to create database", error, branch); }); - emitBranchDatabaseProgress(context, "success", "Created branch database"); + emitBranchDatabaseProgress(context, "success", "Created database"); try { let schemaSetup: BranchDatabaseSchemaSetupResult | null = null; @@ -185,15 +174,15 @@ async function setupBranchDatabase( databaseUrl: database.databaseUrl, directUrl: database.directUrl, }).catch((error) => { - throw schemaSetupFailedError(error, signal.schema!, branch.name, context.runtime.cwd); + throw schemaSetupFailedError(error, signal.schema!, branch, context.runtime.cwd); }); emitBranchDatabaseProgress(context, "success", "Applied database schema"); } else { - skippedSchemaWarning = "No supported Prisma schema source was found. Branch database env vars were created, but schema setup was skipped."; + skippedSchemaWarning = "No supported Prisma schema source was found. 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(", ")}`); + emitBranchDatabaseProgress(context, "success", `Added ${envScopeLabel(branch)} env var${envVars.length === 1 ? "" : "s"} ${envVars.join(", ")}`); if (skippedSchemaWarning) { emitBranchDatabaseWarning(context, skippedSchemaWarning); warnings.push(skippedSchemaWarning); @@ -218,7 +207,7 @@ async function setupBranchDatabase( warnings, }; } catch (error) { - throw await cleanupCreatedBranchDatabaseAfterFailure(context, provider, database, branch.name, error); + throw await cleanupCreatedBranchDatabaseAfterFailure(context, provider, database, branch, error); } } @@ -230,35 +219,34 @@ async function upsertBranchDatabaseEnvVars( database: PreviewBranchDatabaseRecord, envState: BranchDatabaseEnvState, ): Promise { + const scope = envScopeForBranch(branch); const written: string[] = []; await upsertBranchDatabaseEnvVar(context, provider, { projectId, - branchId: branch.id, - className: "preview", + ...scope, key: "DATABASE_URL", value: database.databaseUrl, - existing: envState.branchDatabaseUrl, - branchName: branch.name, + existing: envState.targetDatabaseUrl, + branch, }); written.push("DATABASE_URL"); if (database.directUrl) { await upsertBranchDatabaseEnvVar(context, provider, { projectId, - branchId: branch.id, - className: "preview", + ...scope, key: "DIRECT_URL", value: database.directUrl, - existing: envState.branchDirectUrl, - branchName: branch.name, + existing: envState.targetDirectUrl, + branch, }); written.push("DIRECT_URL"); - } else if (envState.branchDirectUrl) { + } else if (branch.kind === "preview" && envState.targetDirectUrl) { await provider.deleteEnvironmentVariable({ - envVarId: envState.branchDirectUrl.id, + envVarId: envState.targetDirectUrl.id, signal: context.runtime.signal, }).catch((error) => { - throw branchDatabaseSetupFailedError("Failed to remove stale DIRECT_URL", error, branch.name); + throw branchDatabaseSetupFailedError("Failed to remove stale DIRECT_URL", error, branch); }); } @@ -270,12 +258,12 @@ async function upsertBranchDatabaseEnvVar( provider: PreviewAppProvider, options: { projectId: string; - branchId: string; - className: "preview"; + branchId?: string; + className: "production" | "preview"; key: "DATABASE_URL" | "DIRECT_URL"; value: string; existing: PreviewEnvironmentVariableRecord | null; - branchName: string; + branch: BranchDatabaseDeployBranch; }, ): Promise { if (options.existing) { @@ -284,48 +272,52 @@ async function upsertBranchDatabaseEnvVar( value: options.value, signal: context.runtime.signal, }).catch((error) => { - throw branchDatabaseSetupFailedError(`Failed to update ${options.key}`, error, options.branchName); + throw branchDatabaseSetupFailedError(`Failed to update ${options.key}`, error, options.branch); }); return; } await provider.createEnvironmentVariable({ projectId: options.projectId, - branchId: options.branchId, className: options.className, key: options.key, value: options.value, + ...(options.branchId ? { branchId: options.branchId } : {}), signal: context.runtime.signal, }).catch((error) => { - throw branchDatabaseSetupFailedError(`Failed to write ${options.key}`, error, options.branchName); + throw branchDatabaseSetupFailedError(`Failed to write ${options.key}`, error, options.branch); }); } async function inspectBranchDatabaseEnv( provider: PreviewAppProvider, projectId: string, - branchId: string, + branch: BranchDatabaseDeployBranch, signal: AbortSignal, ): Promise { + const scope = envScopeForBranch(branch); const [databaseUrlRows, directUrlRows] = await Promise.all([ provider.listEnvironmentVariables({ projectId, - className: "preview", + className: scope.className, key: "DATABASE_URL", signal, }), provider.listEnvironmentVariables({ projectId, - className: "preview", + className: scope.className, key: "DIRECT_URL", signal, }), ]); + const targetBranchId = branch.kind === "preview" ? branch.id : null; return { - branchDatabaseUrl: findEnvVar(databaseUrlRows, { branchId }), - branchDirectUrl: findEnvVar(directUrlRows, { branchId }), - previewDatabaseUrl: findEnvVar(databaseUrlRows, { branchId: null }), + targetDatabaseUrl: findEnvVar(databaseUrlRows, { branchId: targetBranchId }), + targetDirectUrl: findEnvVar(directUrlRows, { branchId: targetBranchId }), + inheritedPreviewDatabaseUrl: branch.kind === "preview" + ? findEnvVar(databaseUrlRows, { branchId: null }) + : null, }; } @@ -340,9 +332,63 @@ function hasProvidedDatabaseEnvVars(envVars: Record | undefined) return Boolean(envVars && ("DATABASE_URL" in envVars || "DIRECT_URL" in envVars)); } +function envScopeForBranch(branch: BranchDatabaseDeployBranch): { className: "production" | "preview"; branchId?: string } { + return branch.kind === "production" + ? { className: "production" } + : { className: "preview", branchId: branch.id }; +} + +function envScopeLabel(branch: BranchDatabaseDeployBranch): string { + return branch.kind === "production" ? "production" : "branch"; +} + +function getTargetDatabaseEnvVarKeys(envState: BranchDatabaseEnvState): string[] { + return [envState.targetDatabaseUrl, envState.targetDirectUrl] + .filter((variable): variable is PreviewEnvironmentVariableRecord => Boolean(variable)) + .map((variable) => variable.key) + .sort(); +} + +function hasExistingDatabaseEnvForTarget( + branch: BranchDatabaseDeployBranch, + envState: BranchDatabaseEnvState, +): boolean { + if (branch.kind === "production") { + return Boolean(envState.targetDatabaseUrl || envState.targetDirectUrl); + } + + return Boolean(envState.targetDatabaseUrl); +} + +function existingDatabaseEnvReason(branch: BranchDatabaseDeployBranch): "branch-env-exists" | "production-env-exists" { + return branch.kind === "production" ? "production-env-exists" : "branch-env-exists"; +} + +function existingDatabaseEnvWarning(branch: BranchDatabaseDeployBranch, envVars: string[]): string { + if (branch.kind === "production") { + return `Production already has ${envVars.join(" and ")}. Treating it as BYO database configuration and leaving env vars unchanged.`; + } + + return `Branch "${branch.name}" already has DATABASE_URL. Leaving branch database env vars unchanged.`; +} + +function databasePromptSuppressedWarning(branch: BranchDatabaseDeployBranch): string { + if (branch.kind === "production") { + return "This app appears to use DATABASE_URL. Run prisma-cli app deploy --db --yes to create and wire a Prisma Postgres database for this first production deploy."; + } + + return "This app appears to use DATABASE_URL. Run prisma-cli app deploy --db to create an isolated database for this preview branch."; +} + +function databasePromptMessage(branch: BranchDatabaseDeployBranch): string { + return branch.kind === "production" + ? "Create a Prisma Postgres database for production?" + : `Create an isolated database for branch "${branch.name}"?`; +} + function maybeRenderBranchDatabaseSignal( context: CommandContext, - branchName: string, + branch: BranchDatabaseDeployBranch, signal: BranchDatabaseSignal, envState: BranchDatabaseEnvState, ): void { @@ -357,17 +403,21 @@ function maybeRenderBranchDatabaseSignal( signal.databaseUrlReferences.length > 0 ? ` Code ${signal.databaseUrlReferences.slice(0, 3).join(", ")}` : null, - envState.previewDatabaseUrl + envState.inheritedPreviewDatabaseUrl ? " 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` + `Database signal found for ${databaseTargetLabel(branch)}\n` + `${rows.join("\n")}\n\n`, ); } +function databaseTargetLabel(branch: BranchDatabaseDeployBranch): string { + return branch.kind === "production" ? `production branch "${branch.name}"` : `branch "${branch.name}"`; +} + function emitBranchDatabaseProgress( context: CommandContext, status: "pending" | "success", @@ -398,6 +448,33 @@ function emptyBranchDatabaseSetupOutcome(): BranchDatabaseSetupOutcome { }; } +function productionDatabaseSetupAfterFirstDeployError(): CliError { + return usageError( + "Database setup is only available during the first production deploy", + "The selected production app already has a live deployment.", + "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", + ); +} + +function nonInteractiveDatabaseSetupRequiresYesError(branch: BranchDatabaseDeployBranch): CliError { + const command = branch.kind === "production" + ? "prisma-cli app deploy --prod --db --yes" + : `prisma-cli app deploy --branch ${formatCommandArgument(branch.name)} --db --yes`; + + return usageError( + "Database setup requires --yes in non-interactive mode", + "The deploy command received --db, but prompts are not available and --yes was not passed.", + "Pass --yes together with --db to confirm non-interactive database creation.", + [command], + "app", + ); +} + function formatSchemaSetupCommand(command: BranchDatabaseSchemaSetupResult["command"]): string { switch (command) { case "migrate-deploy": @@ -409,21 +486,21 @@ function formatSchemaSetupCommand(command: BranchDatabaseSchemaSetupResult["comm } } -function branchDatabaseSetupFailedError(summary: string, error: unknown, branchName: string): CliError { +function branchDatabaseSetupFailedError(summary: string, error: unknown, branch: BranchDatabaseDeployBranch): 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.", + fix: "Retry the command, or create the database and env vars manually with project env commands.", debug: formatDebugDetails(error), meta: { - branch: branchName, + branch: branch.name, }, exitCode: 1, nextSteps: [ - `prisma-cli app deploy --branch ${formatCommandArgument(branchName)} --db`, - `prisma-cli project env list --branch ${formatCommandArgument(branchName)}`, + formatAppDeployWithDbNextStep(branch), + formatProjectEnvListNextStep(branch), ], }); } @@ -432,22 +509,22 @@ async function cleanupCreatedBranchDatabaseAfterFailure( context: CommandContext, provider: PreviewAppProvider, database: PreviewBranchDatabaseRecord, - branchName: string, + branch: BranchDatabaseDeployBranch, error: unknown, ): Promise { const setupError = error instanceof CliError ? error - : branchDatabaseSetupFailedError("Branch database setup failed", error, branchName); + : branchDatabaseSetupFailedError("Database setup failed", error, branch); - emitBranchDatabaseProgress(context, "pending", "Removing branch database after setup failed"); + emitBranchDatabaseProgress(context, "pending", "Removing database after setup failed"); try { await provider.deleteBranchDatabase({ databaseId: database.id, signal: context.runtime.signal, }); - emitBranchDatabaseProgress(context, "success", "Removed branch database after setup failed"); + emitBranchDatabaseProgress(context, "success", "Removed database after setup failed"); } catch (cleanupError) { - return branchDatabaseCleanupFailedError(setupError, cleanupError, database, branchName); + return branchDatabaseCleanupFailedError(setupError, cleanupError, database, branch); } return setupError; @@ -457,21 +534,21 @@ function branchDatabaseCleanupFailedError( setupError: CliError, cleanupError: unknown, database: PreviewBranchDatabaseRecord, - branchName: string, + branch: BranchDatabaseDeployBranch, ): CliError { const cleanupWhy = cleanupError instanceof Error ? cleanupError.message : String(cleanupError); - const setupWhy = setupError.why ?? "Branch database setup failed."; + const setupWhy = setupError.why ?? "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.", + fix: "Delete the created database from Console or contact Prisma support, then rerun deploy with --db.", debug: formatCombinedDebugDetails(setupError, cleanupError), meta: { ...setupError.meta, - branch: branchName, + branch: branch.name, databaseId: database.id, databaseName: database.name, cleanupFailed: true, @@ -484,7 +561,7 @@ function branchDatabaseCleanupFailedError( function schemaSetupFailedError( error: unknown, schema: BranchDatabaseSchema, - branchName: string, + branch: BranchDatabaseDeployBranch, cwd: string, ): CliError { return new CliError({ @@ -495,7 +572,7 @@ function schemaSetupFailedError( fix: "Fix the Prisma schema or migrations, then rerun deploy with --db.", debug: formatDebugDetails(error), meta: { - branch: branchName, + branch: branch.name, schemaPath: schema.path, source: schema.kind, command: schema.command, @@ -503,29 +580,45 @@ function schemaSetupFailedError( exitCode: 1, nextSteps: [ ...formatSchemaSetupNextSteps(schema, cwd), - `prisma-cli app deploy --branch ${formatCommandArgument(branchName)} --db`, + formatAppDeployWithDbNextStep(branch), ], }); } function unsupportedBranchDatabaseSchemaError( schema: UnsupportedBranchDatabaseSchema, - branchName: string, + branch: BranchDatabaseDeployBranch, context: CommandContext, ): CliError { const sourcePath = path.relative(context.runtime.cwd, schema.path) || defaultUnsupportedSchemaSourcePath(schema); return usageError( - "Branch database setup is not available for this Prisma schema", + "Database setup is not available for this Prisma schema", `${sourcePath} targets ${formatUnsupportedSchemaTarget(schema.target)}, but --db creates Prisma Postgres databases.`, - "Use project env commands to provide a database URL for this branch, or switch the Prisma schema source to PostgreSQL before using --db.", + "Use project env commands to provide a database URL, or switch the Prisma schema source to PostgreSQL before using --db.", [ - `prisma-cli project env add DATABASE_URL= --branch ${formatCommandArgument(branchName)}`, - `prisma-cli app deploy --branch ${formatCommandArgument(branchName)}`, + formatProjectEnvAddNextStep(branch), + `prisma-cli app deploy --branch ${formatCommandArgument(branch.name)}`, ], "app", ); } +function formatAppDeployWithDbNextStep(branch: BranchDatabaseDeployBranch): string { + return `prisma-cli app deploy --branch ${formatCommandArgument(branch.name)} --db`; +} + +function formatProjectEnvListNextStep(branch: BranchDatabaseDeployBranch): string { + return branch.kind === "production" + ? "prisma-cli project env list --role production" + : `prisma-cli project env list --branch ${formatCommandArgument(branch.name)}`; +} + +function formatProjectEnvAddNextStep(branch: BranchDatabaseDeployBranch): string { + return branch.kind === "production" + ? "prisma-cli project env add DATABASE_URL= --role production" + : `prisma-cli project env add DATABASE_URL= --branch ${formatCommandArgument(branch.name)}`; +} + function formatSchemaSetupNextSteps(schema: BranchDatabaseSchema, cwd: string): string[] { const sourcePath = path.relative(cwd, schema.path) || defaultSchemaSourcePath(schema); switch (schema.command) { diff --git a/packages/cli/src/lib/app/production-deploy-gate.ts b/packages/cli/src/lib/app/production-deploy-gate.ts index 983b8c5..abc98f4 100644 --- a/packages/cli/src/lib/app/production-deploy-gate.ts +++ b/packages/cli/src/lib/app/production-deploy-gate.ts @@ -13,14 +13,14 @@ export async function enforceProductionDeployGate( branchKind: BranchKind; prod: boolean; }, -): Promise { +): Promise<{ firstProductionDeploy: boolean }> { if (options.branchKind !== "production") { - return; + return { firstProductionDeploy: false }; } if (!options.appId) { renderFirstProductionDeployLine(context, options.appName); - return; + return { firstProductionDeploy: true }; } const deploymentsResult = await provider.listDeployments(options.appId).catch((error) => { @@ -29,7 +29,7 @@ export async function enforceProductionDeployGate( const currentLiveDeployment = resolveCurrentProductionDeployment(deploymentsResult); if (!currentLiveDeployment) { renderFirstProductionDeployLine(context, options.appName); - return; + return { firstProductionDeploy: true }; } if (!options.prod) { @@ -38,7 +38,7 @@ export async function enforceProductionDeployGate( if (context.flags.yes) { renderProductionDeployYesLine(context); - return; + return { firstProductionDeploy: false }; } if (!canPrompt(context)) { @@ -56,6 +56,8 @@ export async function enforceProductionDeployGate( if (!confirmed) { throw productionDeployCancelledError(); } + + return { firstProductionDeploy: false }; } function resolveCurrentProductionDeployment(result: Awaited>): PreviewDeploymentRecord | null { diff --git a/packages/cli/tests/app-branch-database.test.ts b/packages/cli/tests/app-branch-database.test.ts index 4805961..0e72d63 100644 --- a/packages/cli/tests/app-branch-database.test.ts +++ b/packages/cli/tests/app-branch-database.test.ts @@ -300,6 +300,147 @@ describe("app deploy branch database setup", () => { }); }); + it("deploy --db creates a database, applies schema, and writes production env vars on first production deploy", async () => { + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const branchId = "branch_main"; + const listApps = vi.fn().mockResolvedValue([ + { id: "app_1", name: "hello-world", region: "eu-central-1", liveDeploymentId: null, liveUrl: null }, + ]); + const listDeployments = vi.fn().mockResolvedValue({ + app: { id: "app_1", name: "hello-world", region: "eu-central-1", liveDeploymentId: null, liveUrl: null }, + deployments: [], + }); + const createBranchDatabase = vi.fn().mockResolvedValue({ + id: "db_1", + name: "main", + 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 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", + source: "prisma-orm", + 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: "main", + role: "production", + }), + listApps, + createBranchDatabase, + listEnvironmentVariables: vi.fn().mockResolvedValue([]), + createEnvironmentVariable, + updateEnvironmentVariable: vi.fn(), + deployApp, + listDeployments, + 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: "main", + framework: "hono", + db: true, + }); + + expect(listDeployments).toHaveBeenCalledWith("app_1"); + expect(createBranchDatabase).toHaveBeenCalledWith({ + projectId: "proj_123", + branchId, + branchName: "main", + signal: context.runtime.signal, + }); + expect(runBranchDatabaseSchemaSetup).toHaveBeenCalledWith( + expect.objectContaining({ + databaseUrl: "postgres://pooled", + directUrl: "postgres://direct", + }), + ); + expect(createEnvironmentVariable.mock.calls[0]?.[0]).toEqual({ + projectId: "proj_123", + className: "production", + key: "DATABASE_URL", + value: "postgres://pooled", + signal: context.runtime.signal, + }); + expect(createEnvironmentVariable.mock.calls[1]?.[0]).toEqual({ + projectId: "proj_123", + className: "production", + key: "DIRECT_URL", + value: "postgres://direct", + signal: context.runtime.signal, + }); + expect(createBranchDatabase.mock.invocationCallOrder[0]).toBeLessThan(deployApp.mock.invocationCallOrder[0]); + expect(runBranchDatabaseSchemaSetup.mock.invocationCallOrder[0]).toBeLessThan(deployApp.mock.invocationCallOrder[0]); + expect(result.result.branchDatabase).toEqual({ + status: "created", + database: { + id: "db_1", + name: "main", + }, + envVars: ["DATABASE_URL", "DIRECT_URL"], + schema: { + command: "db-push", + source: "prisma-orm", + path: "prisma/schema.prisma", + }, + }); + }); + it("deploy --db creates a branch database and applies a Prisma Next config before deploying", async () => { const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); const branchId = "branch_feature_next"; @@ -530,6 +671,216 @@ describe("app deploy branch database setup", () => { }); }); + it("deploy --db treats existing production database env vars as BYO DB and leaves them unchanged", async () => { + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const branchId = "branch_main"; + 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 updateEnvironmentVariable = 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; className?: string }) => { + if (options.className !== "production") { + return []; + } + if (options.key === "DATABASE_URL") { + return [{ + id: "env_database_url", + key: "DATABASE_URL", + branchId: null, + className: "production", + isManagedBySystem: false, + }]; + } + if (options.key === "DIRECT_URL") { + return [{ + id: "env_direct_url", + key: "DIRECT_URL", + branchId: null, + className: "production", + 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: "main", + role: "production", + }), + listApps, + createBranchDatabase, + listEnvironmentVariables, + createEnvironmentVariable, + updateEnvironmentVariable, + deployApp, + listDeployments: vi.fn().mockResolvedValue({ + app: { id: "app_1", name: "hello-world", region: "eu-central-1", liveDeploymentId: null, liveUrl: null }, + deployments: [], + }), + 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, + }, + }); + + const result = await runAppDeploy(context, "hello-world", { + projectRef: "proj_123", + branchName: "main", + framework: "hono", + db: true, + }); + + expect(createBranchDatabase).not.toHaveBeenCalled(); + expect(createEnvironmentVariable).not.toHaveBeenCalled(); + expect(updateEnvironmentVariable).not.toHaveBeenCalled(); + expect(deployApp).toHaveBeenCalled(); + expect(result.result.branchDatabase).toEqual({ + status: "skipped", + reason: "production-env-exists", + envVars: ["DATABASE_URL", "DIRECT_URL"], + schema: null, + }); + }); + + it.each([ + { + existingKey: "DATABASE_URL", + envVarId: "env_database_url", + }, + { + existingKey: "DIRECT_URL", + envVarId: "env_direct_url", + }, + ] as const)("deploy --db treats an existing production $existingKey as BYO DB and leaves it unchanged", async ({ existingKey, envVarId }) => { + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const branchId = "branch_main"; + 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 updateEnvironmentVariable = 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; className?: string }) => { + if (options.className !== "production" || options.key !== existingKey) { + return []; + } + + return [{ + id: envVarId, + key: existingKey, + branchId: null, + className: "production", + isManagedBySystem: false, + }]; + }); + + vi.doMock("../src/lib/auth/guard", () => ({ + requireComputeAuth, + })); + vi.doMock("../src/lib/app/preview-provider", () => ({ + createPreviewAppProvider: vi.fn(() => ({ + resolveBranch: vi.fn().mockResolvedValue({ + id: branchId, + name: "main", + role: "production", + }), + listApps, + createBranchDatabase, + listEnvironmentVariables, + createEnvironmentVariable, + updateEnvironmentVariable, + deployApp, + listDeployments: vi.fn().mockResolvedValue({ + app: { id: "app_1", name: "hello-world", region: "eu-central-1", liveDeploymentId: null, liveUrl: null }, + deployments: [], + }), + 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, + }, + }); + + const result = await runAppDeploy(context, "hello-world", { + projectRef: "proj_123", + branchName: "main", + framework: "hono", + db: true, + }); + + expect(createBranchDatabase).not.toHaveBeenCalled(); + expect(createEnvironmentVariable).not.toHaveBeenCalled(); + expect(updateEnvironmentVariable).not.toHaveBeenCalled(); + expect(deployApp).toHaveBeenCalled(); + expect(result.result.branchDatabase).toEqual({ + status: "skipped", + reason: "production-env-exists", + envVars: [existingKey], + schema: null, + }); + }); + it("deploy --db repairs a branch that only has DIRECT_URL", async () => { const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); const branchId = "branch_feature_db"; @@ -887,6 +1238,147 @@ describe("app deploy branch database setup", () => { expect(createBranchDatabase).toHaveBeenCalled(); }); + it("--yes alone does not create a database during first production deploy", async () => { + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const branchId = "branch_main"; + const createBranchDatabase = 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", + }, + }); + + vi.doMock("../src/lib/auth/guard", () => ({ + requireComputeAuth, + })); + vi.doMock("../src/lib/app/preview-provider", () => ({ + createPreviewAppProvider: vi.fn(() => ({ + resolveBranch: vi.fn().mockResolvedValue({ + id: branchId, + name: "main", + role: "production", + }), + listApps: vi.fn().mockResolvedValue([ + { id: "app_1", name: "hello-world", region: "eu-central-1", liveDeploymentId: null, liveUrl: null }, + ]), + createBranchDatabase, + listEnvironmentVariables: vi.fn().mockResolvedValue([]), + createEnvironmentVariable: vi.fn(), + updateEnvironmentVariable: vi.fn(), + deployApp, + listDeployments: vi.fn().mockResolvedValue({ + app: { id: "app_1", name: "hello-world", region: "eu-central-1", liveDeploymentId: null, liveUrl: null }, + deployments: [], + }), + 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: "main", + framework: "hono", + }); + + expect(createBranchDatabase).not.toHaveBeenCalled(); + expect(deployApp).toHaveBeenCalled(); + expect(result.result.branchDatabase).toBeUndefined(); + }); + + it("rejects --db for production apps that already have a live deployment", async () => { + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const branchId = "branch_main"; + 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: vi.fn().mockResolvedValue({ + id: branchId, + name: "main", + role: "production", + }), + listApps: vi.fn().mockResolvedValue([ + { id: "app_1", name: "hello-world", region: "eu-central-1", liveDeploymentId: "dep_live", liveUrl: "https://hello-world.prisma.app" }, + ]), + createBranchDatabase, + listEnvironmentVariables: vi.fn().mockResolvedValue([]), + createEnvironmentVariable: vi.fn(), + updateEnvironmentVariable: vi.fn(), + deployApp, + listDeployments: vi.fn().mockResolvedValue({ + app: { id: "app_1", name: "hello-world", region: "eu-central-1", liveDeploymentId: "dep_live", liveUrl: "https://hello-world.prisma.app" }, + deployments: [{ + id: "dep_live", + status: "running", + createdAt: "2026-06-01T00:00:00.000Z", + url: "https://hello-world.prisma.app", + live: true, + }], + }), + 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: "main", + framework: "hono", + prod: true, + db: true, + })).rejects.toMatchObject({ + code: "USAGE_ERROR", + domain: "app", + summary: "Database setup is only available during the first production deploy", + }); + expect(createBranchDatabase).not.toHaveBeenCalled(); + expect(deployApp).not.toHaveBeenCalled(); + }); + it("rejects --db when deploy also passes database env vars", async () => { const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); const createBranchDatabase = vi.fn(); @@ -936,7 +1428,7 @@ describe("app deploy branch database setup", () => { })).rejects.toMatchObject({ code: "USAGE_ERROR", domain: "app", - summary: "Branch database setup cannot be combined with provided database env vars", + summary: "Database setup cannot be combined with provided database env vars", }); expect(createBranchDatabase).not.toHaveBeenCalled(); expect(deployApp).not.toHaveBeenCalled(); @@ -1228,7 +1720,7 @@ describe("app deploy branch database setup", () => { })).rejects.toMatchObject({ code: "USAGE_ERROR", domain: "app", - summary: "Branch database setup is not available for this Prisma schema", + summary: "Database setup is not available for this Prisma schema", }); expect(createBranchDatabase).not.toHaveBeenCalled(); expect(deployApp).not.toHaveBeenCalled();