@prisma-next/cli 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) hide show
  1. package/README.md +13 -9
  2. package/dist/cli.mjs +259 -12
  3. package/dist/cli.mjs.map +1 -1
  4. package/dist/{client-oXO2WCPD.mjs → client-KgJorIvG.mjs} +72 -60
  5. package/dist/client-KgJorIvG.mjs.map +1 -0
  6. package/dist/{command-helpers-BSb0tRC8.mjs → command-helpers-Bbw1GbwL.mjs} +646 -46
  7. package/dist/command-helpers-Bbw1GbwL.mjs.map +1 -0
  8. package/dist/commands/contract-emit.d.mts.map +1 -1
  9. package/dist/commands/contract-emit.mjs +1 -1
  10. package/dist/commands/contract-infer.d.mts.map +1 -1
  11. package/dist/commands/contract-infer.mjs +1 -1
  12. package/dist/commands/db-init.d.mts.map +1 -1
  13. package/dist/commands/db-init.mjs +32 -7
  14. package/dist/commands/db-init.mjs.map +1 -1
  15. package/dist/commands/db-schema.d.mts.map +1 -1
  16. package/dist/commands/db-schema.mjs +3 -4
  17. package/dist/commands/db-schema.mjs.map +1 -1
  18. package/dist/commands/db-sign.d.mts.map +1 -1
  19. package/dist/commands/db-sign.mjs +12 -10
  20. package/dist/commands/db-sign.mjs.map +1 -1
  21. package/dist/commands/db-update.d.mts.map +1 -1
  22. package/dist/commands/db-update.mjs +41 -11
  23. package/dist/commands/db-update.mjs.map +1 -1
  24. package/dist/commands/db-verify.d.mts.map +1 -1
  25. package/dist/commands/db-verify.mjs +1 -1
  26. package/dist/commands/migrate.d.mts +6 -2
  27. package/dist/commands/migrate.d.mts.map +1 -1
  28. package/dist/commands/migrate.mjs +75 -40
  29. package/dist/commands/migrate.mjs.map +1 -1
  30. package/dist/commands/migration-check.d.mts +4 -3
  31. package/dist/commands/migration-check.d.mts.map +1 -1
  32. package/dist/commands/migration-check.mjs +1 -280
  33. package/dist/commands/migration-graph.d.mts +13 -2
  34. package/dist/commands/migration-graph.d.mts.map +1 -1
  35. package/dist/commands/migration-graph.mjs +2 -137
  36. package/dist/commands/migration-list.d.mts +64 -4
  37. package/dist/commands/migration-list.d.mts.map +1 -1
  38. package/dist/commands/migration-list.mjs +143 -56
  39. package/dist/commands/migration-list.mjs.map +1 -1
  40. package/dist/commands/migration-log.d.mts +10 -1
  41. package/dist/commands/migration-log.d.mts.map +1 -1
  42. package/dist/commands/migration-log.mjs +10 -15
  43. package/dist/commands/migration-log.mjs.map +1 -1
  44. package/dist/commands/migration-new.d.mts.map +1 -1
  45. package/dist/commands/migration-new.mjs +32 -38
  46. package/dist/commands/migration-new.mjs.map +1 -1
  47. package/dist/commands/migration-plan.d.mts +3 -2
  48. package/dist/commands/migration-plan.d.mts.map +1 -1
  49. package/dist/commands/migration-plan.mjs +1 -1
  50. package/dist/commands/migration-show.d.mts +4 -55
  51. package/dist/commands/migration-show.d.mts.map +1 -1
  52. package/dist/commands/migration-show.mjs +61 -153
  53. package/dist/commands/migration-show.mjs.map +1 -1
  54. package/dist/commands/migration-status.d.mts +12 -49
  55. package/dist/commands/migration-status.d.mts.map +1 -1
  56. package/dist/commands/migration-status.mjs +85 -81
  57. package/dist/commands/migration-status.mjs.map +1 -1
  58. package/dist/commands/ref.d.mts +1 -1
  59. package/dist/commands/ref.d.mts.map +1 -1
  60. package/dist/commands/ref.mjs +38 -10
  61. package/dist/commands/ref.mjs.map +1 -1
  62. package/dist/config-loader-B6sJjXTv.mjs.map +1 -1
  63. package/dist/config-loader.d.mts.map +1 -1
  64. package/dist/contract-at-errors-BxP-TOMl.mjs +42 -0
  65. package/dist/contract-at-errors-BxP-TOMl.mjs.map +1 -0
  66. package/dist/{contract-emit-bcrpT-wD.mjs → contract-emit-D-4jrNve.mjs} +25 -10
  67. package/dist/contract-emit-D-4jrNve.mjs.map +1 -0
  68. package/dist/{contract-emit-r4y8Zhf1.mjs → contract-emit-DxcGl4Uq.mjs} +19 -14
  69. package/dist/contract-emit-DxcGl4Uq.mjs.map +1 -0
  70. package/dist/{contract-enrichment-Dani0mMW.mjs → contract-enrichment-a0V5Y_mL.mjs} +4 -25
  71. package/dist/contract-enrichment-a0V5Y_mL.mjs.map +1 -0
  72. package/dist/{contract-infer-BmySmqVT.mjs → contract-infer-D8uEbJuu.mjs} +4 -5
  73. package/dist/{contract-infer-BmySmqVT.mjs.map → contract-infer-D8uEbJuu.mjs.map} +1 -1
  74. package/dist/contract-space-aggregate-loader-DvZwdkrr.mjs +247 -0
  75. package/dist/contract-space-aggregate-loader-DvZwdkrr.mjs.map +1 -0
  76. package/dist/{db-verify-BClPs3ph.mjs → db-verify-v_vUKXTU.mjs} +5 -7
  77. package/dist/{db-verify-BClPs3ph.mjs.map → db-verify-v_vUKXTU.mjs.map} +1 -1
  78. package/dist/exports/control-api.d.mts +3 -3
  79. package/dist/exports/control-api.d.mts.map +1 -1
  80. package/dist/exports/control-api.mjs +3 -3
  81. package/dist/exports/index.d.mts.map +1 -1
  82. package/dist/exports/index.mjs +1 -1
  83. package/dist/exports/index.mjs.map +1 -1
  84. package/dist/exports/init-output.d.mts.map +1 -1
  85. package/dist/exports/init-output.mjs +1 -1
  86. package/dist/extension-pack-inputs-IDvjRCi3.mjs +62 -0
  87. package/dist/extension-pack-inputs-IDvjRCi3.mjs.map +1 -0
  88. package/dist/{framework-components-65gOHkHB.mjs → framework-components-fYXjz_in.mjs} +2 -2
  89. package/dist/{framework-components-65gOHkHB.mjs.map → framework-components-fYXjz_in.mjs.map} +1 -1
  90. package/dist/global-flags-DEHjV8_s.d.mts +34 -0
  91. package/dist/global-flags-DEHjV8_s.d.mts.map +1 -0
  92. package/dist/{graph-render-DJVv0_uf.mjs → graph-render-rFAqZujX.mjs} +2 -2
  93. package/dist/{graph-render-DJVv0_uf.mjs.map → graph-render-rFAqZujX.mjs.map} +1 -1
  94. package/dist/{init-BCJZPWE1.mjs → init-Cv9UzWL5.mjs} +20 -269
  95. package/dist/init-Cv9UzWL5.mjs.map +1 -0
  96. package/dist/{inspect-live-schema-DSRbFoOL.mjs → inspect-live-schema-C6ohV_oQ.mjs} +4 -5
  97. package/dist/{inspect-live-schema-DSRbFoOL.mjs.map → inspect-live-schema-C6ohV_oQ.mjs.map} +1 -1
  98. package/dist/migration-check-BiBJoYYW.mjs +341 -0
  99. package/dist/migration-check-BiBJoYYW.mjs.map +1 -0
  100. package/dist/migration-cli.d.mts.map +1 -1
  101. package/dist/migration-cli.mjs +4 -4
  102. package/dist/migration-cli.mjs.map +1 -1
  103. package/dist/{migration-command-scaffold-Bzd9La5c.mjs → migration-command-scaffold-CjvwO6at.mjs} +4 -5
  104. package/dist/{migration-command-scaffold-Bzd9La5c.mjs.map → migration-command-scaffold-CjvwO6at.mjs.map} +1 -1
  105. package/dist/migration-graph-D7DVUElV.mjs +1232 -0
  106. package/dist/migration-graph-D7DVUElV.mjs.map +1 -0
  107. package/dist/migration-list-styler-BRwF4-gy.mjs +399 -0
  108. package/dist/migration-list-styler-BRwF4-gy.mjs.map +1 -0
  109. package/dist/{migration-plan-CFwqw3Gk.mjs → migration-plan-9DJ7q7_z.mjs} +372 -133
  110. package/dist/migration-plan-9DJ7q7_z.mjs.map +1 -0
  111. package/dist/{migration-types-BXWvz12q.d.mts → migration-types-D2FW63pr.d.mts} +1 -1
  112. package/dist/{migration-types-BXWvz12q.d.mts.map → migration-types-D2FW63pr.d.mts.map} +1 -1
  113. package/dist/{migrations-CwZMa1Ck.mjs → migrations-Cv2jxNNK.mjs} +12 -13
  114. package/dist/migrations-Cv2jxNNK.mjs.map +1 -0
  115. package/dist/{output-BlsrGMEF.mjs → output-B60Gw5fu.mjs} +1 -1
  116. package/dist/{output-BlsrGMEF.mjs.map → output-B60Gw5fu.mjs.map} +1 -1
  117. package/dist/{progress-adapter-DFfvZcYL.mjs → progress-adapter-C644QK8l.mjs} +1 -1
  118. package/dist/{progress-adapter-DFfvZcYL.mjs.map → progress-adapter-C644QK8l.mjs.map} +1 -1
  119. package/dist/ref-advancement-DUZqsue6.mjs +50 -0
  120. package/dist/ref-advancement-DUZqsue6.mjs.map +1 -0
  121. package/dist/terminal-ui-5Y6mrg93.d.mts +133 -0
  122. package/dist/terminal-ui-5Y6mrg93.d.mts.map +1 -0
  123. package/dist/{types--CqjMdk0.d.mts → types-Dt_SfqFm.d.mts} +28 -28
  124. package/dist/types-Dt_SfqFm.d.mts.map +1 -0
  125. package/dist/{verify-Bom75OYI.mjs → verify-DCA9Sldu.mjs} +2 -2
  126. package/dist/{verify-Bom75OYI.mjs.map → verify-DCA9Sldu.mjs.map} +1 -1
  127. package/package.json +35 -24
  128. package/src/commands/contract-emit.ts +19 -7
  129. package/src/commands/contract-infer.ts +1 -1
  130. package/src/commands/db-init.ts +48 -2
  131. package/src/commands/db-sign.ts +9 -5
  132. package/src/commands/db-update.ts +54 -8
  133. package/src/commands/init/hygiene-gitattributes.ts +2 -2
  134. package/src/commands/init/index.ts +2 -1
  135. package/src/commands/init/templates/code-templates.ts +4 -2
  136. package/src/commands/init/templates/env.ts +13 -14
  137. package/src/commands/migrate.ts +125 -44
  138. package/src/commands/migration-check.ts +43 -83
  139. package/src/commands/migration-graph.ts +75 -60
  140. package/src/commands/migration-list.ts +220 -74
  141. package/src/commands/migration-log.ts +8 -14
  142. package/src/commands/migration-new.ts +44 -48
  143. package/src/commands/migration-plan.ts +412 -197
  144. package/src/commands/migration-show.ts +65 -284
  145. package/src/commands/migration-status.ts +127 -124
  146. package/src/commands/ref.ts +53 -8
  147. package/src/control-api/client.ts +0 -1
  148. package/src/control-api/contract-enrichment.ts +6 -42
  149. package/src/control-api/operations/{apply-aggregate.ts → apply.ts} +44 -75
  150. package/src/control-api/operations/contract-emit.ts +14 -6
  151. package/src/control-api/operations/{db-apply-aggregate.ts → db-apply.ts} +19 -19
  152. package/src/control-api/operations/db-init.ts +4 -4
  153. package/src/control-api/operations/db-update.ts +4 -4
  154. package/src/control-api/operations/db-verify.ts +15 -11
  155. package/src/control-api/operations/migration-apply.ts +56 -47
  156. package/src/control-api/types.ts +26 -27
  157. package/src/migration-cli.ts +4 -4
  158. package/src/utils/cli-errors.ts +234 -0
  159. package/src/utils/command-helpers.ts +9 -24
  160. package/src/utils/contract-at-errors.ts +96 -0
  161. package/src/utils/contract-space-aggregate-loader.ts +336 -117
  162. package/src/utils/formatters/migration-graph-layout.ts +1119 -0
  163. package/src/utils/formatters/migration-graph-rows.ts +336 -0
  164. package/src/utils/formatters/migration-graph-tree-render.ts +459 -0
  165. package/src/utils/formatters/migration-list-data-column.ts +115 -0
  166. package/src/utils/formatters/migration-list-graph-topology.ts +368 -0
  167. package/src/utils/formatters/migration-list-render.ts +191 -0
  168. package/src/utils/formatters/migration-list-styler.ts +63 -0
  169. package/src/utils/formatters/migration-list-types.ts +21 -0
  170. package/src/utils/formatters/migrations.ts +37 -46
  171. package/src/utils/glyph-mode.ts +22 -0
  172. package/src/utils/integrity-violation-to-check-failure.ts +130 -0
  173. package/src/utils/plan-resolution.ts +258 -0
  174. package/src/utils/ref-advancement.ts +68 -0
  175. package/src/utils/terminal-ui.ts +42 -1
  176. package/dist/cli-errors-Czmx92Zy.d.mts +0 -3
  177. package/dist/cli-errors-Djtz98Vm.mjs +0 -71
  178. package/dist/cli-errors-Djtz98Vm.mjs.map +0 -1
  179. package/dist/client-oXO2WCPD.mjs.map +0 -1
  180. package/dist/command-helpers-BSb0tRC8.mjs.map +0 -1
  181. package/dist/commands/migration-check.mjs.map +0 -1
  182. package/dist/commands/migration-graph.mjs.map +0 -1
  183. package/dist/contract-emit-bcrpT-wD.mjs.map +0 -1
  184. package/dist/contract-emit-r4y8Zhf1.mjs.map +0 -1
  185. package/dist/contract-enrichment-Dani0mMW.mjs.map +0 -1
  186. package/dist/contract-space-aggregate-loader-BmNQwlws.mjs +0 -160
  187. package/dist/contract-space-aggregate-loader-BmNQwlws.mjs.map +0 -1
  188. package/dist/global-flags-CdE7M0d9.d.mts +0 -15
  189. package/dist/global-flags-CdE7M0d9.d.mts.map +0 -1
  190. package/dist/init-BCJZPWE1.mjs.map +0 -1
  191. package/dist/migration-plan-CFwqw3Gk.mjs.map +0 -1
  192. package/dist/migrations-CwZMa1Ck.mjs.map +0 -1
  193. package/dist/rolldown-runtime-twds-ZHy.mjs +0 -14
  194. package/dist/terminal-ui-BiB_8KNo.mjs +0 -379
  195. package/dist/terminal-ui-BiB_8KNo.mjs.map +0 -1
  196. package/dist/types--CqjMdk0.d.mts.map +0 -1
@@ -1,13 +1,13 @@
1
- import { readFile } from 'node:fs/promises';
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
2
  import type { Contract } from '@prisma-next/contract/types';
3
3
  import { getEmittedArtifactPaths } from '@prisma-next/emitter';
4
4
  import {
5
- type ControlFamilyInstance,
6
5
  createControlStack,
7
6
  hasOperationPreview,
8
7
  type MigrationPlanOperation,
9
8
  type OperationPreview,
10
9
  } from '@prisma-next/framework-components/control';
10
+ import { canonicalizeJson } from '@prisma-next/framework-components/utils';
11
11
  import { MigrationToolsError } from '@prisma-next/migration-tools/errors';
12
12
  import { computeMigrationHash } from '@prisma-next/migration-tools/hash';
13
13
  import { deriveProvidedInvariants } from '@prisma-next/migration-tools/invariants';
@@ -17,10 +17,7 @@ import {
17
17
  writeMigrationPackage,
18
18
  } from '@prisma-next/migration-tools/io';
19
19
  import type { MigrationMetadata } from '@prisma-next/migration-tools/metadata';
20
- import { findLatestMigration } from '@prisma-next/migration-tools/migration-graph';
21
20
  import { writeMigrationTs } from '@prisma-next/migration-tools/migration-ts';
22
- import { parseContractRef } from '@prisma-next/migration-tools/ref-resolution';
23
- import { readRefs } from '@prisma-next/migration-tools/refs';
24
21
  import { notOk, ok, type Result } from '@prisma-next/utils/result';
25
22
  import { Command } from 'commander';
26
23
  import { join, relative } from 'pathe';
@@ -34,24 +31,26 @@ import {
34
31
  errorTargetMigrationNotSupported,
35
32
  errorUnexpected,
36
33
  mapMigrationToolsError,
37
- mapRefResolutionError,
38
34
  } from '../utils/cli-errors';
39
35
  import {
40
36
  addGlobalOptions,
41
37
  getTargetMigrations,
42
- loadMigrationPackages,
43
38
  resolveContractPath,
44
39
  resolveMigrationPaths,
45
40
  setCommandDescriptions,
46
41
  setCommandExamples,
47
42
  } from '../utils/command-helpers';
48
- import { buildContractSpaceAggregate } from '../utils/contract-space-aggregate-loader';
43
+ import {
44
+ buildContractSpaceAggregate,
45
+ loadContractSpaceAggregateForCli,
46
+ } from '../utils/contract-space-aggregate-loader';
49
47
  import { runContractSpaceSeedPhase } from '../utils/contract-space-seed-phase';
50
48
  import { toExtensionInputs } from '../utils/extension-pack-inputs';
51
49
  import { formatStyledHeader } from '../utils/formatters/styled';
52
50
  import { assertFrameworkComponentsCompatible } from '../utils/framework-components';
53
51
  import type { CommonCommandOptions } from '../utils/global-flags';
54
52
  import { type GlobalFlags, parseGlobalFlagsOrExit } from '../utils/global-flags';
53
+ import { resolveFromForPlan, resolveToForPlan } from '../utils/plan-resolution';
55
54
  import { handleResult } from '../utils/result-handler';
56
55
  import { createTerminalUI, type TerminalUI } from '../utils/terminal-ui';
57
56
 
@@ -59,55 +58,116 @@ interface MigrationPlanOptions extends CommonCommandOptions {
59
58
  readonly config?: string;
60
59
  readonly name?: string;
61
60
  readonly from?: string;
61
+ readonly to?: string;
62
62
  }
63
63
 
64
- /**
65
- * Load a predecessor migration's destination contract from its sibling
66
- * `end-contract.json` on disk and route it through the family's
67
- * `ContractSerializer` (via `deserializeContract`) so the in-memory shape
68
- * is the hydrated `Contract` every other caller sees. Bypassing this
69
- * seam was the root cause of TML-2536: a raw `JSON.parse(...) as Contract`
70
- * here let polymorphic `storage.types` entries reach the planner without
71
- * the `kind` discriminator the planner dispatches on.
72
- *
73
- * Throws `CliStructuredError` with:
74
- * - `errorFileNotFound` when the sibling file is missing — the user
75
- * has likely deleted or never authored the snapshot, and the
76
- * message names the file and points them at re-emitting from the
77
- * source.
78
- * - `errorContractValidationFailed` when the JSON parses but the
79
- * family deserializer rejects it (legacy untagged shape, structural
80
- * mismatch, etc.) — the message names the predecessor's path so
81
- * the operator can locate the bad snapshot.
82
- */
83
- async function readPredecessorEndContract(
84
- migrationDir: string,
85
- familyInstance: ControlFamilyInstance<string, unknown>,
86
- ): Promise<Contract> {
87
- const path = join(migrationDir, 'end-contract.json');
88
- let raw: string;
89
- try {
90
- raw = await readFile(path, 'utf-8');
91
- } catch (error) {
92
- if (error instanceof Error && (error as { code?: string }).code === 'ENOENT') {
93
- throw errorFileNotFound(path, {
94
- why: `Predecessor migration is missing its destination contract snapshot at ${path}`,
95
- fix: 'Re-emit the predecessor migration (`prisma-next migration plan` from its source) so its sibling `end-contract.json` is restored, then re-run this command.',
96
- });
97
- }
98
- throw error;
64
+ async function writeSnapshotContractArtifacts(
65
+ packageDir: string,
66
+ contractJson: unknown,
67
+ contractDts: string,
68
+ artifactBasename: 'start-contract' | 'end-contract',
69
+ ): Promise<void> {
70
+ await mkdir(packageDir, { recursive: true });
71
+ const jsonContent = `${canonicalizeJson(contractJson)}\n`;
72
+ const dtsContent = contractDts.endsWith('\n') ? contractDts : `${contractDts}\n`;
73
+ await writeFile(join(packageDir, `${artifactBasename}.json`), jsonContent);
74
+ await writeFile(join(packageDir, `${artifactBasename}.d.ts`), dtsContent);
75
+ }
76
+
77
+ async function writeSnapshotStartContract(
78
+ packageDir: string,
79
+ contractJson: unknown,
80
+ contractDts: string,
81
+ ): Promise<void> {
82
+ await writeSnapshotContractArtifacts(packageDir, contractJson, contractDts, 'start-contract');
83
+ }
84
+
85
+ type PlannerSuccess = {
86
+ readonly plannedOps: readonly MigrationPlanOperation[];
87
+ readonly migrationTsContent: string;
88
+ readonly hasPlaceholders: boolean;
89
+ };
90
+
91
+ type TargetMigrationsApi = NonNullable<ReturnType<typeof getTargetMigrations>>;
92
+
93
+ async function runPlannerLeg(
94
+ planner: ReturnType<TargetMigrationsApi['createPlanner']>,
95
+ migrations: TargetMigrationsApi,
96
+ frameworkComponents: ReturnType<typeof assertFrameworkComponentsCompatible>,
97
+ contract: Contract,
98
+ fromContract: Contract | null,
99
+ spaceId: string,
100
+ ): Promise<Result<PlannerSuccess, CliStructuredError>> {
101
+ const fromSchema = migrations.contractToSchema(fromContract, frameworkComponents);
102
+ const plannerResult = planner.plan({
103
+ contract,
104
+ schema: fromSchema,
105
+ policy: { allowedOperationClasses: ['additive', 'widening', 'destructive', 'data'] },
106
+ fromContract,
107
+ frameworkComponents,
108
+ spaceId,
109
+ });
110
+ if (plannerResult.kind === 'failure') {
111
+ return notOk(
112
+ errorMigrationPlanningFailed({
113
+ conflicts: plannerResult.conflicts as readonly CliErrorConflict[],
114
+ }),
115
+ );
99
116
  }
117
+
118
+ let plannedOps: readonly MigrationPlanOperation[] = [];
119
+ let hasPlaceholders = false;
100
120
  try {
101
- return familyInstance.deserializeContract(JSON.parse(raw) as unknown);
102
- } catch (error) {
103
- if (CliStructuredError.is(error)) {
104
- throw error;
121
+ plannedOps = plannerResult.plan.operations;
122
+ if (plannedOps.length === 0) {
123
+ return notOk(
124
+ errorMigrationPlanningFailed({
125
+ conflicts: [
126
+ {
127
+ kind: 'unsupportedChange',
128
+ summary:
129
+ 'Contract changed but planner produced no operations. ' +
130
+ 'This indicates unsupported or ignored changes.',
131
+ },
132
+ ],
133
+ }),
134
+ );
135
+ }
136
+ } catch (e) {
137
+ if (CliStructuredError.is(e) && e.domain === 'MIG' && e.code === '2001') {
138
+ hasPlaceholders = true;
139
+ } else {
140
+ throw e;
105
141
  }
106
- throw errorContractValidationFailed(
107
- `Predecessor contract at ${path} failed to deserialize: ${error instanceof Error ? error.message : String(error)}`,
108
- { where: { path } },
109
- );
110
142
  }
143
+
144
+ return ok({
145
+ plannedOps,
146
+ migrationTsContent: plannerResult.plan.renderTypeScript(),
147
+ hasPlaceholders,
148
+ });
149
+ }
150
+
151
+ async function writePlannedMigrationPackage(
152
+ packageDir: string,
153
+ fromHash: string | null,
154
+ toHash: string,
155
+ createdAt: Date,
156
+ leg: PlannerSuccess,
157
+ ): Promise<void> {
158
+ const opsForWrite = leg.hasPlaceholders ? [] : leg.plannedOps;
159
+ const metadataWithInvariants: Omit<MigrationMetadata, 'migrationHash'> = {
160
+ from: fromHash,
161
+ to: toHash,
162
+ providedInvariants: deriveProvidedInvariants(opsForWrite),
163
+ createdAt: createdAt.toISOString(),
164
+ };
165
+ const metadata: MigrationMetadata = {
166
+ ...metadataWithInvariants,
167
+ migrationHash: computeMigrationHash(metadataWithInvariants, opsForWrite),
168
+ };
169
+ await writeMigrationPackage(packageDir, metadata, opsForWrite);
170
+ await writeMigrationTs(packageDir, leg.migrationTsContent);
111
171
  }
112
172
 
113
173
  export interface MigrationPlanResult {
@@ -116,6 +176,7 @@ export interface MigrationPlanResult {
116
176
  readonly from: string | null;
117
177
  readonly to: string;
118
178
  readonly dir?: string;
179
+ readonly baselineDir?: string;
119
180
  /**
120
181
  * Extension-space migration packages materialised onto disk during this
121
182
  * `plan` run. Each entry names a `migrations/<spaceId>/<dirName>/`
@@ -125,7 +186,7 @@ export interface MigrationPlanResult {
125
186
  *
126
187
  * Surfacing these in the result (rather than only via `ui.step` log
127
188
  * lines) makes the cross-space side effect explicit to JSON consumers
128
- * and the success-summary renderer — the same multi-space side effect
189
+ * and the success-summary renderer — the same cross-space side effect
129
190
  * that `migrate` will replay.
130
191
  */
131
192
  readonly emittedExtensionDirs: readonly { readonly spaceId: string; readonly dirName: string }[];
@@ -174,6 +235,9 @@ async function executeMigrationPlanCommand(
174
235
  if (options.from) {
175
236
  details.push({ label: 'from', value: options.from });
176
237
  }
238
+ if (options.to) {
239
+ details.push({ label: 'to', value: options.to });
240
+ }
177
241
  if (options.name) {
178
242
  details.push({ label: 'name', value: options.name });
179
243
  }
@@ -207,8 +271,7 @@ async function executeMigrationPlanCommand(
207
271
  );
208
272
  }
209
273
 
210
- // Construct the family instance up-front so on-disk reads (the app
211
- // contract here + every `readPredecessorEndContract` below) cross the
274
+ // Construct the family instance up-front so on-disk contract reads cross the
212
275
  // serializer seam at the read site, not after the planner has already
213
276
  // started dispatching on raw shapes. See TML-2536.
214
277
  const stack = createControlStack(config);
@@ -234,70 +297,84 @@ async function executeMigrationPlanCommand(
234
297
  }),
235
298
  );
236
299
  }
237
- const toStorageHash = rawStorageHash;
300
+ let toStorageHash: string = rawStorageHash;
301
+
302
+ // When `--to <ref>` resolves a non-default destination, these carry its raw
303
+ // artifacts so the planned package's `end-contract.*` is written from the
304
+ // resolved target rather than copied from the emitted `contract.json`.
305
+ let toArtifacts: { contractJson: unknown; contractDts: string } | null = null;
238
306
 
239
- // Read existing migrations and determine "from" contract
240
307
  let fromContract: Contract | null = null;
241
308
  let fromHash: string | null = null;
242
309
  let fromContractSourceDir: string | null = null;
310
+ let snapshotStartContract: { contractJson: unknown; contractDts: string } | null = null;
311
+ let isAutoBaseline = false;
243
312
 
244
- try {
245
- const { bundles, graph } = await loadMigrationPackages(appMigrationsDir);
313
+ const tolerantAggregateResult = await loadContractSpaceAggregateForCli({
314
+ targetId: config.target.targetId,
315
+ migrationsDir,
316
+ appContract: toContract,
317
+ extensionPacks: config.extensionPacks ?? [],
318
+ deserializeContract: (json: unknown) => familyInstance.deserializeContract(json),
319
+ });
320
+ if (!tolerantAggregateResult.ok) {
321
+ return notOk(tolerantAggregateResult.failure);
322
+ }
323
+ const resolutionMember = tolerantAggregateResult.value.app;
246
324
 
247
- if (options.from) {
248
- const refs = await readRefs(resolveMigrationPaths(options.config, config).refsDir);
249
- const refResult = parseContractRef(options.from, { graph, refs });
250
- if (!refResult.ok) {
251
- return notOk(mapRefResolutionError(refResult.failure));
252
- }
253
- fromHash = refResult.value.hash;
254
- const matchingBundle = bundles.find((p) => p.metadata.to === fromHash);
255
- if (!matchingBundle) {
256
- return notOk(
257
- errorUnexpected(
258
- `No migration bundle found for --from "${options.from}" (resolved hash: ${fromHash})`,
259
- {
260
- why: `The ref resolved successfully but no on-disk migration package has an end-contract hash matching ${fromHash}.`,
261
- fix: 'Provide a ref or hash that corresponds to an existing migration package, or run `migration list` to see available migrations.',
262
- },
263
- ),
264
- );
265
- }
266
- fromContractSourceDir = matchingBundle.dirPath;
267
- fromContract = await readPredecessorEndContract(fromContractSourceDir, familyInstance);
268
- } else {
269
- const latestMigration = findLatestMigration(graph);
270
- if (latestMigration) {
271
- fromHash = latestMigration.to;
272
- const leafPkg = bundles.find(
273
- (p) => p.metadata.migrationHash === latestMigration.migrationHash,
274
- );
275
- if (leafPkg) {
276
- fromContractSourceDir = leafPkg.dirPath;
277
- fromContract = await readPredecessorEndContract(fromContractSourceDir, familyInstance);
278
- }
279
- }
280
- }
281
- } catch (error) {
282
- if (MigrationToolsError.is(error)) {
283
- return notOk(mapMigrationToolsError(error));
284
- }
285
- // `readPredecessorEndContract` raises a `CliStructuredError` directly
286
- // for the missing-snapshot case so the operator gets a precise
287
- // why/fix; pass it through unchanged rather than re-wrapping.
288
- if (CliStructuredError.is(error)) {
289
- return notOk(error);
325
+ const resolutionResult = await resolveFromForPlan({
326
+ optionsFrom: options.from,
327
+ member: resolutionMember,
328
+ });
329
+
330
+ if (!resolutionResult.ok) {
331
+ return notOk(resolutionResult.failure);
332
+ }
333
+
334
+ switch (resolutionResult.value.kind) {
335
+ case 'greenfield':
336
+ break;
337
+ case 'graph-node':
338
+ fromHash = resolutionResult.value.fromHash;
339
+ fromContract = resolutionResult.value.fromContract;
340
+ fromContractSourceDir = resolutionResult.value.sourceDir;
341
+ break;
342
+ case 'snapshot':
343
+ fromHash = resolutionResult.value.fromHash;
344
+ fromContract = resolutionResult.value.fromContract;
345
+ snapshotStartContract = {
346
+ contractJson: resolutionResult.value.contractJson,
347
+ contractDts: resolutionResult.value.contractDts,
348
+ };
349
+ break;
350
+ case 'auto-baseline':
351
+ fromHash = resolutionResult.value.fromHash;
352
+ fromContract = resolutionResult.value.fromContract;
353
+ snapshotStartContract = {
354
+ contractJson: resolutionResult.value.contractJson,
355
+ contractDts: resolutionResult.value.contractDts,
356
+ };
357
+ isAutoBaseline = true;
358
+ break;
359
+ }
360
+
361
+ // `--to <ref>` swaps the planner destination to an arbitrary resolved
362
+ // contract (e.g. an ancestor / rollback target). The from-side resolution
363
+ // above is untouched; only the destination + its emitted `end-contract.*`
364
+ // change.
365
+ if (options.to !== undefined) {
366
+ const toResolution = await resolveToForPlan(options.to, {
367
+ member: resolutionMember,
368
+ });
369
+ if (!toResolution.ok) {
370
+ return notOk(toResolution.failure);
290
371
  }
291
- // Wrap unexpected (non-MigrationToolsError) failures from the migration
292
- // load phase in a structured CLI envelope. Letting them throw would
293
- // bypass `handleResult()` and crash the command — see CLI structured-
294
- // errors guideline (CliStructuredError + Result pattern).
295
- const message = error instanceof Error ? error.message : String(error);
296
- return notOk(
297
- errorUnexpected(message, {
298
- why: `Unexpected error while loading migrations: ${message}`,
299
- }),
300
- );
372
+ toContract = toResolution.value.contract;
373
+ toStorageHash = toResolution.value.hash;
374
+ toArtifacts = {
375
+ contractJson: toResolution.value.contractJson,
376
+ contractDts: toResolution.value.contractDts,
377
+ };
301
378
  }
302
379
 
303
380
  // Phase 1 — seed: unconditionally re-emit per-space pinned artefacts
@@ -325,8 +402,10 @@ async function executeMigrationPlanCommand(
325
402
  r.newMigrationDirs.map((dirName) => ({ spaceId: r.spaceId, dirName })),
326
403
  );
327
404
 
328
- // Check for no-op (same hash means no changes)
329
- if (fromHash === toStorageHash) {
405
+ // Check for no-op (same hash means no changes). Auto-baseline is exempt:
406
+ // an empty graph with db ref at the current contract still needs a
407
+ // null → fromHash baseline bundle so migrate can anchor the marker.
408
+ if (fromHash === toStorageHash && !isAutoBaseline) {
330
409
  const result: MigrationPlanResult = {
331
410
  ok: true,
332
411
  noOp: true,
@@ -375,97 +454,204 @@ async function executeMigrationPlanCommand(
375
454
  [config.target, config.adapter, ...(config.extensionPacks ?? [])],
376
455
  );
377
456
 
378
- // Build manifest and write migration package
379
- const timestamp = new Date();
380
- const slug = options.name ?? 'migration';
381
- const dirName = formatMigrationDirName(timestamp, slug);
382
- const packageDir = join(appMigrationsDir, dirName);
383
-
384
- const baseMetadata: Omit<MigrationMetadata, 'migrationHash' | 'providedInvariants'> = {
385
- from: fromHash,
386
- to: toStorageHash,
387
- hints: {
388
- used: [],
389
- applied: [],
390
- plannerVersion: '2.0.0',
391
- },
392
- labels: [],
393
- createdAt: timestamp.toISOString(),
394
- };
457
+ // Write the planned package's destination `end-contract.*`. With `--to`, the
458
+ // resolved target's raw artifacts are written; otherwise the emitted
459
+ // `contract.json` / `contract.d.ts` are copied verbatim (today's behaviour).
460
+ async function writeDestinationEndContract(packageDir: string): Promise<void> {
461
+ if (toArtifacts !== null) {
462
+ await writeSnapshotContractArtifacts(
463
+ packageDir,
464
+ toArtifacts.contractJson,
465
+ toArtifacts.contractDts,
466
+ 'end-contract',
467
+ );
468
+ return;
469
+ }
470
+ const destinationArtifacts = getEmittedArtifactPaths(contractPathAbsolute);
471
+ await copyFilesWithRename(packageDir, [
472
+ { sourcePath: destinationArtifacts.jsonPath, destName: 'end-contract.json' },
473
+ { sourcePath: destinationArtifacts.dtsPath, destName: 'end-contract.d.ts' },
474
+ ]);
475
+ }
395
476
 
396
477
  try {
397
478
  const planner = migrations.createPlanner(familyInstance);
398
- const fromSchema = migrations.contractToSchema(fromContract, frameworkComponents);
399
- const plannerResult = planner.plan({
400
- contract: aggregate.app.contract,
401
- schema: fromSchema,
402
- policy: { allowedOperationClasses: ['additive', 'widening', 'destructive', 'data'] },
403
- fromContract,
404
- frameworkComponents,
405
- spaceId: aggregate.app.spaceId,
406
- });
407
- if (plannerResult.kind === 'failure') {
408
- return notOk(
409
- errorMigrationPlanningFailed({
410
- conflicts: plannerResult.conflicts as readonly CliErrorConflict[],
411
- }),
479
+
480
+ if (
481
+ isAutoBaseline &&
482
+ fromHash !== null &&
483
+ fromContract !== null &&
484
+ snapshotStartContract !== null
485
+ ) {
486
+ const baselineTimestamp = new Date();
487
+ const deltaTimestamp = new Date(baselineTimestamp.getTime() + 60_000);
488
+ const baselineDirName = formatMigrationDirName(baselineTimestamp, 'baseline');
489
+ const deltaDirName = formatMigrationDirName(deltaTimestamp, options.name ?? 'migration');
490
+ const baselinePackageDir = join(appMigrationsDir, baselineDirName);
491
+ const deltaPackageDir = join(appMigrationsDir, deltaDirName);
492
+
493
+ const baselineLeg = await runPlannerLeg(
494
+ planner,
495
+ migrations,
496
+ frameworkComponents,
497
+ fromContract,
498
+ null,
499
+ aggregate.app.spaceId,
412
500
  );
413
- }
501
+ if (!baselineLeg.ok) {
502
+ return notOk(baselineLeg.failure);
503
+ }
414
504
 
415
- // Accessing .operations triggers toOp() on each call. If any call
416
- // is a DataTransformCall with an unfilled placeholder stub, toOp()
417
- // throws PN-MIG-2001. We catch that here so the migration can still
418
- // be scaffolded with `ops: []`; the user fills the placeholder, then
419
- // re-runs `node migration.ts` to attest with the real ops.
420
- let plannedOps: readonly MigrationPlanOperation[] = [];
421
- let hasPlaceholders = false;
422
- try {
423
- plannedOps = plannerResult.plan.operations;
424
- if (plannedOps.length === 0) {
425
- return notOk(
426
- errorMigrationPlanningFailed({
427
- conflicts: [
428
- {
429
- kind: 'unsupportedChange',
430
- summary:
431
- 'Contract changed but planner produced no operations. ' +
432
- 'This indicates unsupported or ignored changes.',
433
- },
434
- ],
435
- }),
436
- );
505
+ await writePlannedMigrationPackage(
506
+ baselinePackageDir,
507
+ null,
508
+ fromHash,
509
+ baselineTimestamp,
510
+ baselineLeg.value,
511
+ );
512
+ await writeSnapshotContractArtifacts(
513
+ baselinePackageDir,
514
+ snapshotStartContract.contractJson,
515
+ snapshotStartContract.contractDts,
516
+ 'end-contract',
517
+ );
518
+
519
+ if (fromHash === toStorageHash) {
520
+ const baselineOps = baselineLeg.value.hasPlaceholders ? [] : baselineLeg.value.plannedOps;
521
+ if (baselineLeg.value.hasPlaceholders) {
522
+ const baselineDir = relative(process.cwd(), baselinePackageDir);
523
+ const result: MigrationPlanResult = {
524
+ ok: true,
525
+ noOp: false,
526
+ from: fromHash,
527
+ to: toStorageHash,
528
+ dir: baselineDir,
529
+ baselineDir,
530
+ operations: [],
531
+ emittedExtensionDirs,
532
+ pendingPlaceholders: true,
533
+ summary:
534
+ 'Planned baseline with placeholder(s) — edit migration.ts then run `node migration.ts` to self-emit',
535
+ timings: { total: Date.now() - startTime },
536
+ };
537
+ return ok(result);
538
+ }
539
+
540
+ const preview = hasOperationPreview(familyInstance)
541
+ ? familyInstance.toOperationPreview(baselineOps)
542
+ : undefined;
543
+ const result: MigrationPlanResult = {
544
+ ok: true,
545
+ noOp: false,
546
+ from: fromHash,
547
+ to: toStorageHash,
548
+ baselineDir: relative(process.cwd(), baselinePackageDir),
549
+ operations: baselineOps.map((op) => ({
550
+ id: op.id,
551
+ label: op.label,
552
+ operationClass: op.operationClass,
553
+ })),
554
+ emittedExtensionDirs,
555
+ ...(preview !== undefined ? { preview } : {}),
556
+ summary: buildAutoBaselinePlanSummary(0, emittedExtensionDirs.length),
557
+ timings: { total: Date.now() - startTime },
558
+ };
559
+ return ok(result);
560
+ }
561
+
562
+ const deltaLeg = await runPlannerLeg(
563
+ planner,
564
+ migrations,
565
+ frameworkComponents,
566
+ aggregate.app.contract(),
567
+ fromContract,
568
+ aggregate.app.spaceId,
569
+ );
570
+ if (!deltaLeg.ok) {
571
+ return notOk(deltaLeg.failure);
437
572
  }
438
- } catch (e) {
439
- if (CliStructuredError.is(e) && e.domain === 'MIG' && e.code === '2001') {
440
- hasPlaceholders = true;
441
- } else {
442
- throw e;
573
+
574
+ await writePlannedMigrationPackage(
575
+ deltaPackageDir,
576
+ fromHash,
577
+ toStorageHash,
578
+ deltaTimestamp,
579
+ deltaLeg.value,
580
+ );
581
+ await writeDestinationEndContract(deltaPackageDir);
582
+ await writeSnapshotStartContract(
583
+ deltaPackageDir,
584
+ snapshotStartContract.contractJson,
585
+ snapshotStartContract.contractDts,
586
+ );
587
+
588
+ const deltaOps = deltaLeg.value.hasPlaceholders ? [] : deltaLeg.value.plannedOps;
589
+ if (deltaLeg.value.hasPlaceholders) {
590
+ const result: MigrationPlanResult = {
591
+ ok: true,
592
+ noOp: false,
593
+ from: fromHash,
594
+ to: toStorageHash,
595
+ dir: relative(process.cwd(), deltaPackageDir),
596
+ baselineDir: relative(process.cwd(), baselinePackageDir),
597
+ operations: [],
598
+ emittedExtensionDirs,
599
+ pendingPlaceholders: true,
600
+ summary:
601
+ 'Planned baseline + migration with placeholder(s) — edit migration.ts then run `node migration.ts` to self-emit',
602
+ timings: { total: Date.now() - startTime },
603
+ };
604
+ return ok(result);
443
605
  }
606
+
607
+ const preview = hasOperationPreview(familyInstance)
608
+ ? familyInstance.toOperationPreview(deltaOps)
609
+ : undefined;
610
+ const result: MigrationPlanResult = {
611
+ ok: true,
612
+ noOp: false,
613
+ from: fromHash,
614
+ to: toStorageHash,
615
+ dir: relative(process.cwd(), deltaPackageDir),
616
+ baselineDir: relative(process.cwd(), baselinePackageDir),
617
+ operations: deltaOps.map((op) => ({
618
+ id: op.id,
619
+ label: op.label,
620
+ operationClass: op.operationClass,
621
+ })),
622
+ emittedExtensionDirs,
623
+ ...(preview !== undefined ? { preview } : {}),
624
+ summary: buildAutoBaselinePlanSummary(deltaOps.length, emittedExtensionDirs.length),
625
+ timings: { total: Date.now() - startTime },
626
+ };
627
+ return ok(result);
444
628
  }
445
629
 
446
- const migrationTsContent = plannerResult.plan.renderTypeScript();
447
-
448
- // Always-attest: compute migrationHash over (metadata, ops). When
449
- // placeholders blocked lowering, ops is `[]` and the hash is computed
450
- // over the empty list — re-emitting after the user fills the placeholder
451
- // produces a different hash (over the real ops). This is intentional;
452
- // there is no on-disk "draft" state.
453
- const opsForWrite = hasPlaceholders ? [] : plannedOps;
454
- const metadataWithInvariants: Omit<MigrationMetadata, 'migrationHash'> = {
455
- ...baseMetadata,
456
- providedInvariants: deriveProvidedInvariants(opsForWrite),
457
- };
458
- const metadata: MigrationMetadata = {
459
- ...metadataWithInvariants,
460
- migrationHash: computeMigrationHash(metadataWithInvariants, opsForWrite),
461
- };
630
+ const timestamp = new Date();
631
+ const slug = options.name ?? 'migration';
632
+ const dirName = formatMigrationDirName(timestamp, slug);
633
+ const packageDir = join(appMigrationsDir, dirName);
462
634
 
463
- await writeMigrationPackage(packageDir, metadata, opsForWrite);
464
- const destinationArtifacts = getEmittedArtifactPaths(contractPathAbsolute);
465
- await copyFilesWithRename(packageDir, [
466
- { sourcePath: destinationArtifacts.jsonPath, destName: 'end-contract.json' },
467
- { sourcePath: destinationArtifacts.dtsPath, destName: 'end-contract.d.ts' },
468
- ]);
635
+ const deltaLeg = await runPlannerLeg(
636
+ planner,
637
+ migrations,
638
+ frameworkComponents,
639
+ aggregate.app.contract(),
640
+ fromContract,
641
+ aggregate.app.spaceId,
642
+ );
643
+ if (!deltaLeg.ok) {
644
+ return notOk(deltaLeg.failure);
645
+ }
646
+
647
+ await writePlannedMigrationPackage(
648
+ packageDir,
649
+ fromHash,
650
+ toStorageHash,
651
+ timestamp,
652
+ deltaLeg.value,
653
+ );
654
+ await writeDestinationEndContract(packageDir);
469
655
  if (fromContractSourceDir !== null) {
470
656
  const sourceArtifacts = getEmittedArtifactPaths(
471
657
  join(fromContractSourceDir, 'end-contract.json'),
@@ -474,10 +660,15 @@ async function executeMigrationPlanCommand(
474
660
  { sourcePath: sourceArtifacts.jsonPath, destName: 'start-contract.json' },
475
661
  { sourcePath: sourceArtifacts.dtsPath, destName: 'start-contract.d.ts' },
476
662
  ]);
663
+ } else if (snapshotStartContract !== null) {
664
+ await writeSnapshotStartContract(
665
+ packageDir,
666
+ snapshotStartContract.contractJson,
667
+ snapshotStartContract.contractDts,
668
+ );
477
669
  }
478
- await writeMigrationTs(packageDir, migrationTsContent);
479
670
 
480
- if (hasPlaceholders) {
671
+ if (deltaLeg.value.hasPlaceholders) {
481
672
  const result: MigrationPlanResult = {
482
673
  ok: true,
483
674
  noOp: false,
@@ -494,6 +685,7 @@ async function executeMigrationPlanCommand(
494
685
  return ok(result);
495
686
  }
496
687
 
688
+ const plannedOps = deltaLeg.value.plannedOps;
497
689
  const preview = hasOperationPreview(familyInstance)
498
690
  ? familyInstance.toOperationPreview(plannedOps)
499
691
  : undefined;
@@ -542,6 +734,7 @@ export function createMigrationPlanCommand(): Command {
542
734
  setCommandExamples(command, [
543
735
  'prisma-next migration plan',
544
736
  'prisma-next migration plan --name add-users-table',
737
+ 'prisma-next migration plan --to <migration-dir>^ --name rollback',
545
738
  ]);
546
739
  addGlobalOptions(command)
547
740
  .option('--config <path>', 'Path to prisma-next.config.ts')
@@ -550,6 +743,10 @@ export function createMigrationPlanCommand(): Command {
550
743
  '--from <contract>',
551
744
  'Starting contract reference (hash, prefix, ref name, migration dir name, <dir>^, or ./path)',
552
745
  )
746
+ .option(
747
+ '--to <contract>',
748
+ 'Destination contract reference (hash, prefix, ref name, migration dir name, <dir>^, or ./path); defaults to the emitted contract',
749
+ )
553
750
  .action(async (options: MigrationPlanOptions) => {
554
751
  const flags = parseGlobalFlagsOrExit(options);
555
752
  const startTime = Date.now();
@@ -593,6 +790,17 @@ function buildPlanSummary(plannedOpsCount: number, emittedExtensionDirsCount: nu
593
790
  return `${base}; materialised ${emittedExtensionDirsCount} ${noun}`;
594
791
  }
595
792
 
793
+ function buildAutoBaselinePlanSummary(
794
+ deltaOpsCount: number,
795
+ emittedExtensionDirsCount: number,
796
+ ): string {
797
+ const base = `Planned baseline + ${deltaOpsCount} operation(s)`;
798
+ if (emittedExtensionDirsCount === 0) return base;
799
+ const noun =
800
+ emittedExtensionDirsCount === 1 ? 'extension-space migration' : 'extension-space migrations';
801
+ return `${base}; materialised ${emittedExtensionDirsCount} ${noun}`;
802
+ }
803
+
596
804
  export function formatMigrationPlanOutput(result: MigrationPlanResult, flags: GlobalFlags): string {
597
805
  const lines: string[] = [];
598
806
  const useColor = flags.color !== false;
@@ -672,6 +880,9 @@ export function formatMigrationPlanOutput(result: MigrationPlanResult, flags: Gl
672
880
 
673
881
  lines.push(dim_(`from: ${result.from}`));
674
882
  lines.push(dim_(`to: ${result.to}`));
883
+ if (result.baselineDir) {
884
+ lines.push(dim_(`Baseline → ${result.baselineDir}`));
885
+ }
675
886
  if (result.dir) {
676
887
  lines.push(dim_(`App space → ${result.dir}`));
677
888
  }
@@ -689,8 +900,12 @@ export function formatMigrationPlanOutput(result: MigrationPlanResult, flags: Gl
689
900
  // (`prisma-next migrate`) regardless of how many spaces were
690
901
  // materialised — `db update` is a dev-time convenience, not the
691
902
  // canonical replay step.
903
+ const reviewTarget =
904
+ result.baselineDir !== undefined && result.dir !== undefined
905
+ ? `${result.baselineDir} and ${result.dir}`
906
+ : (result.baselineDir ?? result.dir ?? '<dir>');
692
907
  lines.push(
693
- `Next: review ${green_(result.dir ?? '<dir>')} if needed, then run ${green_('prisma-next migrate')}.`,
908
+ `Next: review ${green_(reviewTarget)} if needed, then run ${green_('prisma-next migrate')}.`,
694
909
  );
695
910
 
696
911
  if (result.preview && result.preview.statements.length > 0) {