diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index 7c5a08fd26a3..75dfca615cd3 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -907,7 +907,7 @@ Runner-level failure during apply (`db init`, `db update`, `migrate`): the plan' ### MIGRATION.DESTRUCTIVE_CHANGES -The planned operations include destructive changes (e.g. DROP) and the command was run without explicit consent. `db update` asks for that consent instead of failing: interactively it asks you to type the name of the database it is about to change, and outside an interactive terminal it is granted by `--confirm ` (`--yes` accepts declared prompt defaults and never grants consent; `--confirm` is read only when the run is non-interactive or `--yes` is set, so a script run from a terminal needs `--no-interactive --confirm `). The name is the `database` a driver connection object carries, or the connection URL's first path segment, else its host, falling back to the target id. A run with nobody to ask and no `--confirm` settles as `CLI.CONSENT_REQUIRED` at exit 2; a run whose prompt is cancelled settles as `CLI.PROMPT_CANCELLED` at exit 3. `--dry-run` never asks — it settles as this error instead. Use it to preview the operations first. +The planned operations include destructive changes (e.g. DROP) and the command was run without explicit consent. `db update` asks for that consent instead of failing: interactively it asks you to type the name of the database it is about to change, and outside an interactive terminal it is granted by `--confirm ` (`--yes` accepts declared prompt defaults and never grants consent; `--confirm` is read only when the run is non-interactive or `--yes` is set, so a script run from a terminal needs `--no-interactive --confirm `). The name is the `database` a driver connection object carries, or the connection URL's first path segment, else its host, falling back to the target id. A run with nobody to ask and no `--confirm` settles as `CLI.CONSENT_REQUIRED` at exit 2; a run whose prompt is cancelled settles as `CLI.PROMPT_CANCELLED` at exit 3. `--dry-run` never asks — it settles as this error instead. Use it to preview the operations first. `migration plan` raises the same refusal before writing an auto-baseline package (planned on an empty migrations directory from the `db` ref) whose operations would remove data when applied; there the consent token is the project directory name, so a non-interactive run passes `--no-interactive --confirm `, and a consented re-run that no longer plans the consented baseline settles as `MIGRATION.CONSENT_PLAN_MISMATCH`. Meta at the `migration plan` site: `destructiveOperations`, `planHash`. ### MIGRATION.DIR_EXISTS @@ -943,7 +943,7 @@ A migration package on disk is corrupt: the `migrationHash` stored in `migration ### MIGRATION.HASH_NOT_IN_GRAPH -A contract hash the user supplied (or that a ref resolved to) is not a node in the on-disk migration graph — raised during plan resolution (`migration plan --from`), `ref set`, and `migration new --from`. The envelope lists the reachable hashes and suggests a valid one or running `migration plan` to introduce it. Meta: `hash`/`resolvedHash`, `reachableHashes` or `reachableRefs`, sometimes `graphTipHash`; none at the `migration new` site. +A contract hash the user supplied (or that a ref resolved to) is not a node in the on-disk migration graph — raised during plan resolution (`migration plan --from`), `ref set`, and `migration new --from` (including `--from` on an empty migrations directory, where there is no migration target it could name). The envelope lists the reachable hashes and suggests a valid one or running `migration plan` to introduce it. Meta: `hash`/`resolvedHash`, `reachableHashes` or `reachableRefs`, sometimes `graphTipHash`; none at the `migration new` sites. ### MIGRATION.INVALID_DEFAULT_EXPORT @@ -1083,7 +1083,7 @@ The `providedInvariants` stored in `migration.json` disagrees with the canonical ### MIGRATION.REF_AMBIGUOUS -A contract or migration reference prefix matches more than one candidate (raised by the shared ref-resolution mapper used across CLI commands). Provide a longer prefix or the full hash. Meta: `input`, `candidates`, `grammar`. +A contract or migration reference prefix matches more than one candidate (raised by the shared ref-resolution mapper used across CLI commands, and by `migration new --from` when the prefix matches several migration target hashes). Provide a longer prefix or the full hash. Meta: `input`, `candidates`, and at the shared-mapper site `grammar`. ### MIGRATION.REF_INVALID_FORMAT diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-new.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-new.ts index f53dca3cce1e..d230410d2ece 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-new.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-new.ts @@ -127,23 +127,44 @@ export async function executeMigrationNewCommand( let fromHash: string | null = null; - if (packages.length > 0) { - if (options.from) { - const match = packages.find((p) => p.metadata.to.startsWith(options.from!)); - if (!match) { - return notOk( - errorRuntime('MIGRATION.HASH_NOT_IN_GRAPH', 'Starting contract not found', { - why: `No migration with to hash matching "${options.from}" exists in ${appMigrationsRelative}`, - fix: 'Check that the --from hash matches a known migration target hash.', - }), - ); - } - fromHash = match.metadata.to; - } else { - const latestMigration = findLatestMigration(graph); - if (latestMigration) { - fromHash = latestMigration.to; - } + if (options.from !== undefined) { + if (packages.length === 0) { + return notOk( + errorRuntime('MIGRATION.HASH_NOT_IN_GRAPH', '--from has no meaning on an empty graph', { + why: `--from "${options.from}" was passed, but ${appMigrationsRelative} contains no migrations, so there is no migration target hash it could name.`, + fix: 'Omit --from to scaffold the first migration (it records a baseline origin). `migration new --from` accepts the full 64-hex target hash of an existing migration, or a unique prefix of one.', + }), + ); + } + const matchedHashes = [ + ...new Set( + packages + .filter((p) => p.metadata.to.startsWith(options.from ?? '')) + .map((p) => p.metadata.to), + ), + ].sort(); + if (matchedHashes.length === 0) { + return notOk( + errorRuntime('MIGRATION.HASH_NOT_IN_GRAPH', 'Starting contract not found', { + why: `No migration with to hash matching "${options.from}" exists in ${appMigrationsRelative}`, + fix: 'Check that the --from hash matches a known migration target hash. `migration new --from` accepts the full 64-hex target hash of an existing migration, or a unique prefix of one.', + }), + ); + } + if (matchedHashes.length > 1) { + return notOk( + errorRuntime('MIGRATION.REF_AMBIGUOUS', `Ambiguous --from prefix: "${options.from}"`, { + why: `"${options.from}" is a prefix of ${matchedHashes.length} migration target hashes in ${appMigrationsRelative}: ${matchedHashes.join(', ')}`, + fix: 'Provide a longer prefix or the full 64-hex target hash to disambiguate.', + meta: { input: options.from, candidates: matchedHashes }, + }), + ); + } + fromHash = matchedHashes[0] ?? null; + } else if (packages.length > 0) { + const latestMigration = findLatestMigration(graph); + if (latestMigration) { + fromHash = latestMigration.to; } } diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-plan.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-plan.ts index c21c91e2e3f6..bbf6ce15940c 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-plan.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-plan.ts @@ -30,7 +30,9 @@ import { join, relative } from 'pathe'; import { type CliErrorConflict, CliStructuredError, + errorConsentPlanMismatch, errorContractValidationFailed, + errorDestructiveChanges, errorFileNotFound, errorMigrationPlanningFailed, errorTargetMigrationNotSupported, @@ -44,6 +46,7 @@ import { import { toExtensionInputs } from '../../utils/extension-pack-inputs'; import { assertFrameworkComponentsCompatible } from '../../utils/framework-components'; import { createProjectSpecifierResolver } from '../../utils/project-import-root'; +import type { DestructivePlanOperation } from '../types'; import { buildContractSpaceAggregate, loadContractSpaceAggregateForCli, @@ -52,6 +55,7 @@ import { type ContractSpaceSeedPhaseRecord, runContractSpaceSeedPhase, } from './contract-space-seed-phase'; +import { computePlanHash } from './plan-identity'; import { resolveFromForPlan, resolveToForPlan } from './plan-resolution'; function isEnoent(error: unknown): boolean { @@ -67,6 +71,30 @@ export interface MigrationPlanOptions { readonly name?: string; readonly from?: string; readonly to?: string; + /** + * Consent to the auto-baseline plan a prior `MIGRATION.DESTRUCTIVE_CHANGES` + * refusal named by its `planHash`. The consented run recomputes the baseline + * plan and refuses with `MIGRATION.CONSENT_PLAN_MISMATCH` when it differs. + */ + readonly consent?: { readonly planHash: string }; + /** + * Extension-space migration packages a refused first run of this same + * invocation already materialised. The consented re-run finds them on disk + * (its seed phase reports `unchanged`), so the caller threads them back in + * to keep `emittedExtensionDirs` and the summary describing the whole + * invocation, not just the second run. + */ + readonly carryEmittedExtensionDirs?: readonly { + readonly spaceId: string; + readonly dirName: string; + }[]; +} + +/** The verdict a `MIGRATION.DESTRUCTIVE_CHANGES` plan refusal carries in its meta. */ +export interface DestructiveBaselineVerdict { + readonly destructiveOperations: ReadonlyArray; + /** Content hash of the refused baseline plan; consent is granted against it. */ + readonly planHash: string; } type PlannerSuccess = { @@ -133,6 +161,11 @@ async function runPlannerLeg( } catch (e) { if (CliStructuredError.is(e) && e.code === 'MIGRATION.UNFILLED_PLACEHOLDER') { hasPlaceholders = true; + // The operations that DID resolve still matter: the destructive-consent + // check must see them, or a placeholder would smuggle a destructive + // baseline past the prompt. Writers stay gated on hasPlaceholders. + const settled = await Promise.allSettled(plannerResult.plan.operations); + plannedOps = settled.flatMap((entry) => (entry.status === 'fulfilled' ? [entry.value] : [])); } else { throw e; } @@ -167,6 +200,54 @@ async function writePlannedMigrationPackage( await writeMigrationTs(packageDir, leg.migrationTsContent); } +/** + * The consent check for an auto-baseline write, mirroring `db update`'s + * destructive-changes refusal: a baseline leg carrying destructive operations + * is only written when the caller consents to that exact plan by its hash. + * Runs before the baseline and delta packages are written, so a refusal + * leaves the app-space migrations directory untouched (the extension seed + * phase runs earlier and unconditionally, as it does for no-op runs). + * A leg with unfilled placeholders is still checked over the operations + * that did resolve. Returns `null` when the write may proceed. + */ +function refuseUnconsentedDestructiveBaseline( + leg: PlannerSuccess, + baselineToHash: string, + consent: { readonly planHash: string } | undefined, +): CliStructuredError | null { + const ops = leg.plannedOps; + const destructiveOps = ops.filter((op) => op.operationClass === 'destructive'); + if (destructiveOps.length === 0) { + return null; + } + const planHash = computePlanHash({ + operations: ops.map((op) => ({ + id: op.id, + label: op.label, + operationClass: op.operationClass, + })), + destination: { storageHash: baselineToHash }, + }); + if (consent === undefined) { + const verdict: DestructiveBaselineVerdict = { + destructiveOperations: destructiveOps.map((op) => ({ id: op.id, label: op.label })), + planHash, + }; + return errorDestructiveChanges( + `The baseline migration contains ${destructiveOps.length} destructive operation(s) that require confirmation`, + { + why: 'The migrations directory is empty, so planning writes a baseline derived from the `db` ref — and that baseline contains operations that would remove data when the migration is applied.', + fix: 'Re-run `prisma migration plan` and type the project directory name when asked, or pass `--no-interactive --confirm ` where there is nobody to ask.', + meta: { ...verdict }, + }, + ); + } + if (consent.planHash !== planHash) { + return errorConsentPlanMismatch({ consentedPlanHash: consent.planHash, planHash }); + } + return null; +} + export interface MigrationPlanResult { readonly ok: boolean; readonly noOp: boolean; @@ -191,6 +272,13 @@ export interface MigrationPlanResult { readonly id: string; readonly label: string; readonly operationClass: string; + /** + * cwd-relative package directory the operation was written to. Set when + * one plan run writes more than one package (the two-package + * auto-baseline path), so renderers can attribute each operation to the + * package that actually contains it. + */ + readonly packageDir?: string; }[]; /** * Family-agnostic textual preview of the migration plan operations. @@ -199,6 +287,12 @@ export interface MigrationPlanResult { */ readonly preview?: OperationPreview; readonly summary: string; + /** + * Origin-resolution caveats the user must see, e.g. the default `db` ref + * sitting behind the graph tip. Rendered as warn summaries by the human + * presentation and carried verbatim for JSON consumers. + */ + readonly warnings?: readonly string[]; /** * When true, `migration.ts` was written but contains unfilled * `placeholder(...)` calls. The user must edit the file and then run @@ -350,12 +444,26 @@ async function executeMigrationPlanCommandInner( return notOk(resolutionResult.failure); } + const warnings: string[] = []; + const warnBehindTip = (behind: { + readonly refName: string; + readonly refHash: string; + readonly tipHash: string; + }): void => { + warnings.push( + `The default origin ref '${behind.refName}' points at ${behind.refHash}, which is not the latest migration (${behind.tipHash}). Planning from it forks the migration graph; pass --from to choose the origin explicitly.`, + ); + }; + switch (resolutionResult.value.kind) { case 'greenfield': break; case 'graph-node': fromHash = resolutionResult.value.fromHash; fromContract = resolutionResult.value.fromContract; + if (resolutionResult.value.defaultOriginBehindTip !== undefined) { + warnBehindTip(resolutionResult.value.defaultOriginBehindTip); + } break; case 'ref': fromHash = resolutionResult.value.fromHash; @@ -365,6 +473,9 @@ async function executeMigrationPlanCommandInner( contractJson: resolutionResult.value.contractJson, contractDts: resolutionResult.value.contractDts, }; + if (resolutionResult.value.defaultOriginBehindTip !== undefined) { + warnBehindTip(resolutionResult.value.defaultOriginBehindTip); + } break; case 'auto-baseline': fromHash = resolutionResult.value.fromHash; @@ -415,9 +526,19 @@ async function executeMigrationPlanCommandInner( for (const record of seedResult.seeded) { callbacks?.onSeeded?.(record); } - const emittedExtensionDirs = seedResult.seeded.flatMap((r) => + const seededThisRun = seedResult.seeded.flatMap((r) => r.newMigrationDirs.map((dirName) => ({ spaceId: r.spaceId, dirName })), ); + const carried = options.carryEmittedExtensionDirs ?? []; + const emittedExtensionDirs = [ + ...carried, + ...seededThisRun.filter( + (entry) => + !carried.some( + (prior) => prior.spaceId === entry.spaceId && prior.dirName === entry.dirName, + ), + ), + ]; // Check for no-op (same hash means no changes). Auto-baseline is exempt: // an empty graph with db ref at the current contract still needs a @@ -430,6 +551,7 @@ async function executeMigrationPlanCommandInner( to: toStorageHash, operations: [], emittedExtensionDirs, + ...(warnings.length > 0 ? { warnings } : {}), summary: 'No changes detected between contracts', timings: { total: Date.now() - startTime }, }; @@ -524,6 +646,15 @@ async function executeMigrationPlanCommandInner( return notOk(baselineLeg.failure); } + const consentFailure = refuseUnconsentedDestructiveBaseline( + baselineLeg.value, + fromHash, + options.consent, + ); + if (consentFailure !== null) { + return notOk(consentFailure); + } + await writePlannedMigrationPackage( baselinePackageDir, null, @@ -549,6 +680,7 @@ async function executeMigrationPlanCommandInner( baselineDir, operations: [], emittedExtensionDirs, + ...(warnings.length > 0 ? { warnings } : {}), pendingPlaceholders: true, summary: 'Planned baseline with placeholder(s) — edit migration.ts then run `node migration.ts` to self-emit', @@ -573,7 +705,8 @@ async function executeMigrationPlanCommandInner( })), emittedExtensionDirs, ...(preview !== undefined ? { preview } : {}), - summary: buildAutoBaselinePlanSummary(0, emittedExtensionDirs.length), + ...(warnings.length > 0 ? { warnings } : {}), + summary: buildAutoBaselinePlanSummary(baselineOps.length, 0, emittedExtensionDirs.length), timings: { total: Date.now() - startTime }, }; return ok(result); @@ -607,6 +740,7 @@ async function executeMigrationPlanCommandInner( contractDts: snapshotStartContract.contractDts, }); + const baselineOps = baselineLeg.value.hasPlaceholders ? [] : baselineLeg.value.plannedOps; const deltaOps = deltaLeg.value.hasPlaceholders ? [] : deltaLeg.value.plannedOps; if (deltaLeg.value.hasPlaceholders) { const result: MigrationPlanResult = { @@ -618,6 +752,7 @@ async function executeMigrationPlanCommandInner( baselineDir: relative(cwd, baselinePackageDir), operations: [], emittedExtensionDirs, + ...(warnings.length > 0 ? { warnings } : {}), pendingPlaceholders: true, summary: 'Planned baseline + migration with placeholder(s) — edit migration.ts then run `node migration.ts` to self-emit', @@ -626,8 +761,9 @@ async function executeMigrationPlanCommandInner( return ok(result); } + const mergedOps = [...baselineOps, ...deltaOps]; const preview = hasOperationPreview(familyInstance) - ? familyInstance.toOperationPreview(deltaOps) + ? familyInstance.toOperationPreview(mergedOps) : undefined; const result: MigrationPlanResult = { ok: true, @@ -636,14 +772,31 @@ async function executeMigrationPlanCommandInner( to: toStorageHash, dir: relative(cwd, deltaPackageDir), baselineDir: relative(cwd, baselinePackageDir), - operations: deltaOps.map((op) => ({ - id: op.id, - label: op.label, - operationClass: op.operationClass, - })), + // Baseline ops travel with the delta ops so consumers (including the + // destructive warn-summary) see everything this run wrote, each + // attributed to the package that contains it. + operations: [ + ...baselineOps.map((op) => ({ + id: op.id, + label: op.label, + operationClass: op.operationClass, + packageDir: relative(cwd, baselinePackageDir), + })), + ...deltaOps.map((op) => ({ + id: op.id, + label: op.label, + operationClass: op.operationClass, + packageDir: relative(cwd, deltaPackageDir), + })), + ], emittedExtensionDirs, ...(preview !== undefined ? { preview } : {}), - summary: buildAutoBaselinePlanSummary(deltaOps.length, emittedExtensionDirs.length), + ...(warnings.length > 0 ? { warnings } : {}), + summary: buildAutoBaselinePlanSummary( + baselineOps.length, + deltaOps.length, + emittedExtensionDirs.length, + ), timings: { total: Date.now() - startTime }, }; return ok(result); @@ -693,6 +846,7 @@ async function executeMigrationPlanCommandInner( dir: relative(cwd, packageDir), operations: [], emittedExtensionDirs, + ...(warnings.length > 0 ? { warnings } : {}), pendingPlaceholders: true, summary: 'Planned migration with placeholder(s) — edit migration.ts then run `node migration.ts` to self-emit', @@ -718,6 +872,7 @@ async function executeMigrationPlanCommandInner( })), emittedExtensionDirs, ...(preview !== undefined ? { preview } : {}), + ...(warnings.length > 0 ? { warnings } : {}), summary: buildPlanSummary(plannedOps.length, emittedExtensionDirs.length), timings: { total: Date.now() - startTime }, }; @@ -761,10 +916,11 @@ function buildPlanSummary(plannedOpsCount: number, emittedExtensionDirsCount: nu } function buildAutoBaselinePlanSummary( + baselineOpsCount: number, deltaOpsCount: number, emittedExtensionDirsCount: number, ): string { - const base = `Planned baseline + ${deltaOpsCount} operation(s)`; + const base = `Planned baseline (${baselineOpsCount} operation(s)) + ${deltaOpsCount} operation(s)`; if (emittedExtensionDirsCount === 0) return base; const noun = emittedExtensionDirsCount === 1 ? 'extension-space migration' : 'extension-space migrations'; diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/plan-resolution.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/plan-resolution.ts index c529002b7a58..04381c3387d8 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/plan-resolution.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/plan-resolution.ts @@ -25,15 +25,32 @@ export function looksLikeFullHash(input: string): boolean { return FULL_HASH_PATTERN.test(input); } +/** + * Set when the origin was derived from the `db` ref by default (no `--from`) + * and that ref sits on an in-graph node that is not the graph tip. Planning + * from it forks the graph, so the caller must surface it to the user. + */ +export interface DefaultOriginBehindTip { + readonly refName: string; + readonly refHash: string; + readonly tipHash: string; +} + export type FromResolution = | { kind: 'greenfield'; fromHash: null; fromContract: null } - | { kind: 'graph-node'; fromHash: string; fromContract: Contract } + | { + kind: 'graph-node'; + fromHash: string; + fromContract: Contract; + defaultOriginBehindTip?: DefaultOriginBehindTip; + } | { kind: 'ref'; fromHash: string; fromContract: Contract; contractDts: string; contractJson: unknown; + defaultOriginBehindTip?: DefaultOriginBehindTip; } | { kind: 'auto-baseline'; @@ -52,6 +69,25 @@ function graphIsEmpty(space: AggregateContractSpace): boolean { return space.packages.length === 0; } +/** + * The graph tip, or `null` when the graph is empty or already forked — + * a forked graph has no single tip to compare the default ref against. + */ +function findUnambiguousTip(graph: MigrationGraph): string | null { + try { + return findLatestMigration(graph)?.to ?? null; + } catch (error) { + // Any graph-shape error (AMBIGUOUS_TARGET, NO_INITIAL_MIGRATION, + // NO_TARGET) means there is no single tip to compare the default ref + // against; the warning is skipped rather than failing a plan that never + // consulted the tip before. + if (MigrationToolsError.is(error)) { + return null; + } + throw error; + } +} + function getReachableRefs( refs: Refs, graph: MigrationGraph, @@ -197,11 +233,25 @@ export async function resolveFromForPlan( if (!dbRef) { return ok({ kind: 'greenfield', fromHash: null, fromContract: null }); } - return resolveFromPolicy( + const resolved = await resolveFromPolicy( { hash: dbRef.hash, provenance: { kind: 'ref', refName: 'db' } }, input, refs, ); + if (!resolved.ok) { + return resolved; + } + const value = resolved.value; + if (value.kind === 'ref' || value.kind === 'graph-node') { + const tipHash = findUnambiguousTip(graph); + if (tipHash !== null && tipHash !== value.fromHash) { + return ok({ + ...value, + defaultOriginBehindTip: { refName: 'db', refHash: value.fromHash, tipHash }, + }); + } + } + return resolved; } const refResult = parseContractRef(optionsFrom, { graph, refs }); diff --git a/packages/1-framework/3-tooling/cli/src/orm/db/consent.ts b/packages/1-framework/3-tooling/cli/src/orm/db/consent.ts index 3b27d4496cb7..bf732db51afa 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/db/consent.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/db/consent.ts @@ -23,7 +23,9 @@ export function errorConsentTokenUnresolved(targetId: string): CliStructuredErro } /** A prompt that names nothing cannot be consented to knowingly. */ -export function errorConsentOperationsMissing(): CliStructuredError { +export function errorConsentOperationsMissing(options?: { + readonly previewCommand?: string; +}): CliStructuredError { return new CliStructuredError( 'CLI.CONSENT_OPERATIONS_MISSING', 'The plan was refused as destructive but named no operations to confirm.', @@ -33,21 +35,25 @@ export function errorConsentOperationsMissing(): CliStructuredError { { kind: 'run-command', label: 'Preview the plan', - command: 'prisma-cli db update --dry-run', + command: options?.previewCommand ?? 'prisma-cli db update --dry-run', }, ], }, ); } +/** The indented per-operation list every destructive-consent question renders. */ +export function destructiveOperationList(operations: readonly DestructivePlanOperation[]): string { + return operations.map((operation) => ` - ${operation.label}`).join('\n'); +} + /** The question the user answers before anything is dropped. */ export function destructiveConsentQuestion( operations: readonly DestructivePlanOperation[], token: string, ): string { - const listed = operations.map((operation) => ` - ${operation.label}`).join('\n'); return [ `Apply ${operations.length} destructive operation(s) to ${token}? Data they remove cannot be recovered:`, - listed, + destructiveOperationList(operations), ].join('\n'); } diff --git a/packages/1-framework/3-tooling/cli/src/orm/migration/plan.ts b/packages/1-framework/3-tooling/cli/src/orm/migration/plan.ts index d4e14344b5cc..1ff1632eecd9 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/migration/plan.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/migration/plan.ts @@ -1,3 +1,4 @@ +import { blindCast } from '@internal/utils/casts'; import { ifDefined } from '@internal/utils/defined'; import type { Block, Presentations, Text, TreeNode } from '@prisma/cli-engine'; import { flag } from '@prisma/cli-engine'; @@ -5,12 +6,19 @@ import type { NextAction } from '@prisma/cli-engine/protocol'; import { notOk, ok } from '@prisma/cli-engine/protocol'; import { join } from 'pathe'; import type { ContractSpaceSeedPhaseRecord } from '../../control-api/operations/contract-space-seed-phase'; -import type { MigrationPlanResult } from '../../control-api/operations/migration-plan'; +import type { + DestructiveBaselineVerdict, + MigrationPlanResult, +} from '../../control-api/operations/migration-plan'; import { executeMigrationPlanCommand } from '../../control-api/operations/migration-plan'; +import type { DestructivePlanOperation } from '../../control-api/types'; +import { ERROR_CODE_DESTRUCTIVE_CHANGES } from '../../utils/cli-errors'; import { previewBlockHeader } from '../../utils/formatters/migrations'; import { runCommandAction } from '../../utils/next-actions'; import { ormConfigSection } from '../config-section'; +import { destructiveOperationList, errorConsentOperationsMissing } from '../db/consent'; import { defineOrmCommand } from '../define-command'; +import { consentToken } from '../init-inputs'; import { normalizeError } from '../normalize-error'; import { appMigrationsDirFor, @@ -53,12 +61,24 @@ function outcomeFields(result: MigrationPlanResult, migrationsRelative: string): }; } -function operationNodes(result: MigrationPlanResult): readonly TreeNode[] { - return result.operations.map((operation) => - operation.operationClass === 'destructive' - ? { label: operation.label, status: 'warn' } - : { label: operation.label }, - ); +/** + * One tree root per written package: operations carrying a `packageDir` (the + * two-package auto-baseline path) group under their own directory, in first- + * appearance order; the rest fall under the app-space package directory. + */ +function operationRoots(result: MigrationPlanResult): readonly TreeNode[] { + const roots = new Map(); + for (const operation of result.operations) { + const label = operation.packageDir ?? result.dir ?? 'operations'; + const children = roots.get(label) ?? []; + children.push( + operation.operationClass === 'destructive' + ? { label: operation.label, status: 'warn' } + : { label: operation.label }, + ); + roots.set(label, children); + } + return [...roots.entries()].map(([label, children]) => ({ label, children })); } function operationBlocks(result: MigrationPlanResult): readonly Block[] { @@ -71,7 +91,7 @@ function operationBlocks(result: MigrationPlanResult): readonly Block[] { return [ { kind: 'tree', - roots: [{ label: result.dir ?? 'operations', children: operationNodes(result) }], + roots: operationRoots(result), }, ...(destructive ? [ @@ -104,15 +124,28 @@ function previewBlocks(result: MigrationPlanResult): readonly Block[] { ]; } +function warningBlocks(result: MigrationPlanResult): readonly Block[] { + return (result.warnings ?? []).map((text): Block => ({ kind: 'summary', status: 'warn', text })); +} + function planBlocks(result: MigrationPlanResult, migrationsRelative: string): readonly Block[] { const outcome = outcomeFields(result, migrationsRelative); if (result.noOp) { - return [{ kind: 'summary', status: 'ok', text: 'No changes detected' }, outcome]; + return [ + ...warningBlocks(result), + { kind: 'summary', status: 'ok', text: 'No changes detected' }, + outcome, + ]; } if (result.pendingPlaceholders === true) { - return [{ kind: 'summary', status: 'warn', text: result.summary }, outcome]; + return [ + ...warningBlocks(result), + { kind: 'summary', status: 'warn', text: result.summary }, + outcome, + ]; } return [ + ...warningBlocks(result), { kind: 'summary', status: 'ok', text: result.summary }, ...operationBlocks(result), outcome, @@ -183,13 +216,25 @@ function planPresentations(inputs: { }; } +/** The question the user answers before a destructive baseline is written. */ +function destructiveBaselineQuestion(operations: readonly DestructivePlanOperation[]): string { + return [ + `Write a baseline migration containing ${operations.length} destructive operation(s)? Applying it would remove data that cannot be recovered:`, + destructiveOperationList(operations), + ].join('\n'); +} + export const migrationPlanCommand = defineOrmCommand({ help: { summary: 'Plan a migration from contract changes', description: 'Compares the emitted contract against the latest on-disk migration state\n' + 'and produces a new migration package with the required operations.\n' + - 'Offline — does not consult the database.', + 'On an empty migrations directory a baseline package is derived from the\n' + + '`db` ref first; a baseline containing destructive operations is only\n' + + 'written with your consent: the command asks you to type the project\n' + + 'directory name, or takes `--confirm ` where there is nobody\n' + + 'to ask. Offline — does not consult the database.', examples: [ 'migration plan', // biome-ignore lint/plugin/no-family-vocabulary: a migration slug a user would plausibly type, not a schema concept @@ -215,10 +260,17 @@ export const migrationPlanCommand = defineOrmCommand({ }, needs: { config: ormConfigSection }, handler: async (args, ctx) => { + // Dirs the seed phase materialised across this invocation's run(s): the + // consented re-run finds them already on disk, so its own seed records + // come back `unchanged` and the accumulated list is threaded back in. + const seededDirs: { spaceId: string; dirName: string }[] = []; const seeded = (record: ContractSpaceSeedPhaseRecord): void => { if (record.action !== 'updated') { return; } + for (const dirName of record.newMigrationDirs) { + seededDirs.push({ spaceId: record.spaceId, dirName }); + } const step = `Seed contract space ${record.spaceId}`; ctx.report({ kind: 'step-started', step, id: record.spaceId }); ctx.report({ @@ -230,18 +282,57 @@ export const migrationPlanCommand = defineOrmCommand({ }); }; - const planned = await executeMigrationPlanCommand( - { - config: ctx.config, - cwd: ctx.cwd, - configPath: projectConfigPathFor(ctx.cwd), - ...ifDefined('name', args.flags.name), - ...ifDefined('from', args.flags.from), - ...ifDefined('to', args.flags.to), - }, - Date.now(), - { onSeeded: seeded }, - ); + const plan = (consent?: { readonly planHash: string }) => + executeMigrationPlanCommand( + { + config: ctx.config, + cwd: ctx.cwd, + configPath: projectConfigPathFor(ctx.cwd), + ...ifDefined('name', args.flags.name), + ...ifDefined('from', args.flags.from), + ...ifDefined('to', args.flags.to), + ...ifDefined('consent', consent), + ...ifDefined( + 'carryEmittedExtensionDirs', + consent !== undefined && seededDirs.length > 0 ? [...seededDirs] : undefined, + ), + }, + Date.now(), + { onSeeded: seeded }, + ); + + let planned = await plan(); + // The destructive verdict is the planner's own: an auto-baseline whose + // operations would remove data is refused before anything is written. + // Consent is asked for here and the plan re-run carrying the refused + // plan's hash — the operation layer refuses if the recomputed baseline is + // no longer that plan. Mirrors the `db update` consent flow. + if (!planned.ok && planned.failure.code === ERROR_CODE_DESTRUCTIVE_CHANGES) { + const verdict = blindCast< + Partial, + 'the meta envelope is produced by refuseUnconsentedDestructiveBaseline; presence is checked below' + >(planned.failure.meta ?? {}); + if ( + verdict.destructiveOperations === undefined || + verdict.destructiveOperations.length === 0 || + verdict.planHash === undefined + ) { + return notOk( + normalizeError( + errorConsentOperationsMissing({ previewCommand: 'prisma migration plan' }), + ), + ); + } + const token = consentToken(ctx.cwd); + const granted = await ctx.prompt.consent( + destructiveBaselineQuestion(verdict.destructiveOperations), + { token }, + ); + if (!granted) { + return notOk(normalizeError(planned.failure)); + } + planned = await plan({ planHash: verdict.planHash }); + } if (!planned.ok) { return notOk(normalizeError(planned.failure)); } diff --git a/packages/1-framework/3-tooling/cli/test/orm/fixtures/offline-project.ts b/packages/1-framework/3-tooling/cli/test/orm/fixtures/offline-project.ts index ef72f27af337..59759cc5a24c 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/fixtures/offline-project.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/fixtures/offline-project.ts @@ -135,6 +135,9 @@ export async function seedDbRef(options: { /** * The planner the fake target hands back. `plan` replays whatever operations * the test asked for; `emptyMigration` renders the stub `migration new` writes. + * With `throwOnOperations`, any scripted `operations` still resolve alongside + * the rejection — mirroring a real plan where some operations resolve and a + * placeholder op rejects. */ export interface FakePlannerScript { readonly operations?: readonly MigrationPlanOperation[]; @@ -152,7 +155,10 @@ function fakePlanner(script: FakePlannerScript): Record { operations: script.throwOnOperations === undefined ? (script.operations ?? [ADDITIVE_OP]).map((op) => Promise.resolve(op)) - : [Promise.reject(script.throwOnOperations)], + : [ + ...(script.operations ?? []).map((op) => Promise.resolve(op)), + Promise.reject(script.throwOnOperations), + ], renderTypeScript: () => '// planned migration\n', }, } diff --git a/packages/1-framework/3-tooling/cli/test/orm/migration-new.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migration-new.test.ts index 5dc9bb6976a7..ee73caa5ac47 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/migration-new.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/migration-new.test.ts @@ -1,3 +1,4 @@ +import { existsSync } from 'node:fs'; import { readdir, readFile } from 'node:fs/promises'; import { createTestCli } from '@prisma/cli-engine/testing'; import { join } from 'pathe'; @@ -159,6 +160,111 @@ describe('migration new', () => { expect(run.presented?.data).toMatchObject({ from: HASH_FROM }); }); + it('errors when --from is passed on an empty migrations directory', async () => { + const project = await createOfflineProject({ storageHash: HASH_TO }); + + const run = await harness(project).run(['migration', 'new', '--from', 'beef1', '--json'], { + cwd: project.dir, + }); + + expect(run.exitCode).toBe(2); + const terminal = run.json.at(-1); + const envelope = + terminal !== undefined && terminal.kind === 'result' ? terminal.envelope : undefined; + expect(envelope).toMatchObject({ + ok: false, + error: { code: 'MIGRATION.HASH_NOT_IN_GRAPH' }, + }); + expect(existsSync(project.appMigrationsDir)).toBe(false); + }); + + it('errors when --from is a prefix of several migration targets', async () => { + const otherHash = `beef${'2'.repeat(60)}`; + const project = await createOfflineProject({ storageHash: HASH_TO }); + await seedMigrationPackage({ + appMigrationsDir: project.appMigrationsDir, + dirName: '20260101T0000_initial', + from: null, + to: HASH_FROM, + }); + await seedMigrationPackage({ + appMigrationsDir: project.appMigrationsDir, + dirName: '20260102T0000_second', + from: HASH_FROM, + to: otherHash, + }); + + const run = await harness(project).run(['migration', 'new', '--from', 'beef', '--json'], { + cwd: project.dir, + }); + + expect(run.exitCode).toBe(2); + const terminal = run.json.at(-1); + const envelope = + terminal !== undefined && terminal.kind === 'result' ? terminal.envelope : undefined; + expect(envelope).toMatchObject({ + ok: false, + error: { + code: 'MIGRATION.REF_AMBIGUOUS', + meta: { input: 'beef', candidates: [HASH_FROM, otherHash] }, + }, + }); + expect(await scaffoldedDirs(project)).toEqual([ + '20260101T0000_initial', + '20260102T0000_second', + ]); + }); + + it('treats --from "" as a prefix, not as an absent flag', async () => { + const otherHash = `f00d${'4'.repeat(60)}`; + const project = await createOfflineProject({ storageHash: HASH_TO }); + await seedMigrationPackage({ + appMigrationsDir: project.appMigrationsDir, + dirName: '20260101T0000_initial', + from: null, + to: HASH_FROM, + }); + await seedMigrationPackage({ + appMigrationsDir: project.appMigrationsDir, + dirName: '20260102T0000_second', + from: HASH_FROM, + to: otherHash, + }); + + const run = await harness(project).run(['migration', 'new', '--from', '', '--json'], { + cwd: project.dir, + }); + + expect(run.exitCode).toBe(2); + expect(run.json.at(-1)).toMatchObject({ + kind: 'result', + envelope: { ok: false, error: { code: 'MIGRATION.REF_AMBIGUOUS' } }, + }); + }); + + it('accepts a prefix shared only by packages with the same target hash', async () => { + const project = await createOfflineProject({ storageHash: HASH_TO }); + await seedMigrationPackage({ + appMigrationsDir: project.appMigrationsDir, + dirName: '20260101T0000_left', + from: null, + to: HASH_FROM, + }); + await seedMigrationPackage({ + appMigrationsDir: project.appMigrationsDir, + dirName: '20260102T0000_right', + from: HASH_TO, + to: HASH_FROM, + }); + + const run = await harness(project).run(['migration', 'new', '--from', 'beef', '--json'], { + cwd: project.dir, + }); + + expect(run.exitCode).toBe(0); + expect(run.presented?.data).toMatchObject({ from: HASH_FROM }); + }); + it('errors when --from matches no migration target', async () => { const project = await createOfflineProject({ storageHash: HASH_TO }); await seedMigrationPackage({ diff --git a/packages/1-framework/3-tooling/cli/test/orm/migration-plan.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migration-plan.test.ts index 9db66288d97e..215942e27673 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/migration-plan.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/migration-plan.test.ts @@ -1,9 +1,10 @@ import { readdir } from 'node:fs/promises'; import { computeMigrationHash } from '@internal/migration-tools/hash'; import { createTestCli } from '@prisma/cli-engine/testing'; -import { join } from 'pathe'; +import { basename, join } from 'pathe'; import { afterEach, describe, expect, it } from 'vitest'; import { BIN_COMMANDS, BIN_GROUPS } from '../../src/orm/cli'; +import { errorUnfilledPlaceholder } from '../../src/utils/cli-errors'; import { ADDITIVE_OP, contractJson, @@ -206,6 +207,251 @@ describe('migration plan', () => { }); }); + it('warns when the default origin ref is not the latest migration', async () => { + const HASH_MID = `abba${'4'.repeat(60)}`; + const project = await createOfflineProject({ storageHash: HASH_TO }); + await seedMigrationPackage({ + appMigrationsDir: project.appMigrationsDir, + dirName: '20260101T0000_initial', + from: null, + to: HASH_FROM, + }); + await seedMigrationPackage({ + appMigrationsDir: project.appMigrationsDir, + dirName: '20260102T0000_second', + from: HASH_FROM, + to: HASH_MID, + }); + await seedContractSnapshot({ migrationsDir: project.migrationsDir, storageHash: HASH_FROM }); + await seedDbRef({ appMigrationsDir: project.appMigrationsDir, storageHash: HASH_FROM }); + + const run = await harness(project).run(['migration', 'plan'], { + cwd: project.dir, + isTty: { stdout: true }, + }); + + expect(run.exitCode).toBe(0); + const data = run.presented?.data as { warnings?: readonly string[] } | undefined; + const warnings = data?.warnings ?? []; + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("'db'"); + expect(warnings[0]).toContain(HASH_FROM); + expect(warnings[0]).toContain(HASH_MID); + expect(run.presented?.presentation.human).toContainEqual({ + kind: 'summary', + status: 'warn', + text: warnings[0], + }); + }); + + it('does not warn when the default origin ref sits at the latest migration', async () => { + const project = await plannableProject(); + + const run = await harness(project).run(['migration', 'plan'], { cwd: project.dir }); + + expect(run.exitCode).toBe(0); + expect(run.presented?.data).not.toHaveProperty('warnings'); + }); + + describe('auto-baseline consent', () => { + /** An empty graph whose db ref demands a destructive baseline. */ + async function destructiveBaselineProject(): Promise { + const project = await createOfflineProject({ storageHash: HASH_TO }); + await seedContractSnapshot({ migrationsDir: project.migrationsDir, storageHash: HASH_FROM }); + await seedDbRef({ appMigrationsDir: project.appMigrationsDir, storageHash: HASH_FROM }); + return project; + } + const destructiveScript = { operations: [ADDITIVE_OP, DESTRUCTIVE_OP] } as const; + + it('refuses non-interactively without --confirm and writes nothing', async () => { + const project = await destructiveBaselineProject(); + + const run = await harness(project, { script: destructiveScript }).run( + ['migration', 'plan', '--json'], + { cwd: project.dir }, + ); + + expect(run.exitCode).toBe(2); + const terminal = run.json.at(-1); + const envelope = + terminal !== undefined && terminal.kind === 'result' ? terminal.envelope : undefined; + expect(envelope).toMatchObject({ + ok: false, + error: { code: 'CLI.CONSENT_REQUIRED', meta: { consentToken: basename(project.dir) } }, + }); + expect(await plannedDirs(project)).toEqual([]); + }); + + it('names every destructive operation in the question', async () => { + const project = await destructiveBaselineProject(); + + const run = await harness(project, { script: destructiveScript }).run(['migration', 'plan'], { + cwd: project.dir, + isTty: { stdin: true, stdout: true, stderr: true }, + stdin: `${basename(project.dir)}\n`, + }); + + expect(run.stderr).toContain('Drop table "legacy"'); + }); + + it('writes the baseline once consent is typed', async () => { + const project = await destructiveBaselineProject(); + + const run = await harness(project, { script: destructiveScript }).run( + ['migration', 'plan', '--name', 'delta', '--json'], + { cwd: project.dir, isTty: { stdin: true }, answers: [basename(project.dir)] }, + ); + const dirs = await plannedDirs(project); + + expect(run.exitCode).toBe(0); + expect(dirs.map((entry) => entry.replace(/^\d+T\d+_/, ''))).toEqual(['baseline', 'delta']); + }); + + it('writes the baseline when --confirm carries the project directory name', async () => { + const project = await destructiveBaselineProject(); + + const run = await harness(project, { script: destructiveScript }).run( + ['migration', 'plan', '--confirm', basename(project.dir), '--json'], + { cwd: project.dir }, + ); + + expect(run.exitCode).toBe(0); + expect((await plannedDirs(project)).length).toBe(2); + }); + + it('still asks when the destructive baseline also carries a placeholder', async () => { + const project = await destructiveBaselineProject(); + + const run = await harness(project, { + script: { + operations: [ADDITIVE_OP, DESTRUCTIVE_OP], + throwOnOperations: errorUnfilledPlaceholder('backfill'), + }, + }).run(['migration', 'plan', '--json'], { cwd: project.dir }); + + expect(run.exitCode).toBe(2); + const terminal = run.json.at(-1); + const envelope = + terminal !== undefined && terminal.kind === 'result' ? terminal.envelope : undefined; + expect(envelope).toMatchObject({ ok: false, error: { code: 'CLI.CONSENT_REQUIRED' } }); + expect(await plannedDirs(project)).toEqual([]); + }); + + it('writes the placeholder baseline once --confirm grants consent', async () => { + const project = await destructiveBaselineProject(); + + const run = await harness(project, { + script: { + operations: [ADDITIVE_OP, DESTRUCTIVE_OP], + throwOnOperations: errorUnfilledPlaceholder('backfill'), + }, + }).run(['migration', 'plan', '--confirm', basename(project.dir), '--json'], { + cwd: project.dir, + }); + + expect(run.exitCode).toBe(0); + expect(run.presented?.data).toMatchObject({ pendingPlaceholders: true }); + expect((await plannedDirs(project)).length).toBe(2); + }); + + it('reports extension dirs the refused first run seeded once consent is granted', async () => { + const EXT_HASH = `f00d${'5'.repeat(60)}`; + const extMetadataBase = { + from: null, + to: EXT_HASH, + providedInvariants: [], + createdAt: '2026-01-01T00:00:00.000Z', + }; + const project = await destructiveBaselineProject(); + + const run = await harness(project, { + script: destructiveScript, + overrides: { + extensions: [ + { + kind: 'extension', + id: 'cipherstash', + familyId: 'sql', + targetId: 'postgres', + version: '1.0.0', + create: () => ({}), + contractSpace: { + contractJson: contractJson(EXT_HASH), + headRef: { hash: EXT_HASH, invariants: [] }, + migrations: [ + { + dirName: '0001_seed', + metadata: { + ...extMetadataBase, + migrationHash: computeMigrationHash(extMetadataBase, []), + }, + ops: [], + }, + ], + }, + }, + ], + }, + }).run(['migration', 'plan', '--confirm', basename(project.dir), '--json'], { + cwd: project.dir, + }); + + expect(run.exitCode).toBe(0); + expect(run.presented?.data).toMatchObject({ + emittedExtensionDirs: [{ spaceId: 'cipherstash', dirName: '0001_seed' }], + }); + }); + + it('never asks when the baseline is purely additive', async () => { + const project = await destructiveBaselineProject(); + + const run = await harness(project).run(['migration', 'plan', '--json'], { + cwd: project.dir, + }); + + expect(run.exitCode).toBe(0); + expect((await plannedDirs(project)).length).toBe(2); + }); + }); + + it('includes the baseline ops in the operations of a two-package auto-baseline plan', async () => { + const project = await createOfflineProject({ storageHash: HASH_TO }); + await seedContractSnapshot({ migrationsDir: project.migrationsDir, storageHash: HASH_FROM }); + await seedDbRef({ appMigrationsDir: project.appMigrationsDir, storageHash: HASH_FROM }); + + const run = await harness(project, { + script: { operations: [ADDITIVE_OP, DESTRUCTIVE_OP] }, + }).run(['migration', 'plan', '--confirm', basename(project.dir)], { + cwd: project.dir, + isTty: { stdout: true }, + }); + + expect(run.exitCode).toBe(0); + const data = run.presented?.data as { + baselineDir: string; + dir: string; + operations: readonly { id: string; operationClass: string; packageDir?: string }[]; + }; + expect(data.operations).toHaveLength(4); + expect(data.operations.filter((op) => op.operationClass === 'destructive')).toHaveLength(2); + expect(data.operations.map((op) => op.packageDir)).toEqual([ + data.baselineDir, + data.baselineDir, + data.dir, + data.dir, + ]); + const tree = (run.presented?.presentation.human ?? []).find( + (block) => block.kind === 'tree', + ) as { roots: readonly { label: string; children: readonly unknown[] }[] }; + expect(tree.roots.map((root) => root.label)).toEqual([data.baselineDir, data.dir]); + expect(tree.roots.map((root) => root.children.length)).toEqual([2, 2]); + expect(run.presented?.presentation.human).toContainEqual({ + kind: 'summary', + status: 'warn', + text: 'This migration contains destructive operations that may cause data loss.', + }); + }); + it('renders extension-space dirs under the configured migrations directory', async () => { const EXT_HASH = `f00d${'3'.repeat(60)}`; const extMetadataBase = {