Public/Migration/Invoke-DataverseMigration.ps1

function Invoke-DataverseMigration {
    <#
    .SYNOPSIS
        Migrates data from one Dataverse environment to another.

    .DESCRIPTION
        Performs a complete data migration from a source to target Dataverse environment.
        This combines export and import into a single operation:
        1. Exports data from source environment to a temporary file
        2. Imports data into target environment
        3. Cleans up temporary file

        Use this for environment-to-environment migrations.

        This cmdlet wraps the ppds-migrate CLI tool.

    .PARAMETER SourceConnection
        Connection string for the source Dataverse environment.

    .PARAMETER TargetConnection
        Connection string for the target Dataverse environment.

    .PARAMETER SchemaPath
        Path to the schema.xml file defining entities to migrate.

    .PARAMETER BatchSize
        Records per batch for import operations.
        Default: 1000

    .PARAMETER BypassPlugins
        Bypass custom plugin execution on the target environment.

    .PARAMETER PassThru
        Return a migration result object with statistics.

    .EXAMPLE
        Invoke-DataverseMigration `
            -SourceConnection "AuthType=ClientSecret;Url=https://source.crm.dynamics.com;ClientId=xxx;ClientSecret=xxx" `
            -TargetConnection "AuthType=ClientSecret;Url=https://target.crm.dynamics.com;ClientId=xxx;ClientSecret=xxx" `
            -SchemaPath "./schema.xml"

        Migrates data from source to target environment.

    .EXAMPLE
        $result = Invoke-DataverseMigration `
            -SourceConnection $sourceConn `
            -TargetConnection $targetConn `
            -SchemaPath "./schema.xml" `
            -BypassPlugins `
            -PassThru

        Migrates with plugin bypass and returns statistics.

    .OUTPUTS
        None by default. PSCustomObject with migration statistics if -PassThru is specified.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$SourceConnection,

        [Parameter(Mandatory)]
        [string]$TargetConnection,

        [Parameter(Mandatory)]
        [string]$SchemaPath,

        [Parameter()]
        [int]$BatchSize = 1000,

        [Parameter()]
        [switch]$BypassPlugins,

        [Parameter()]
        [switch]$PassThru
    )

    # Validate schema file exists
    if (-not (Test-Path $SchemaPath)) {
        throw "Schema file not found: $SchemaPath"
    }

    # Get the CLI tool
    $cliPath = Get-PpdsMigrateCli

    # Build arguments
    $cliArgs = @(
        'migrate'
        '--source-connection', $SourceConnection
        '--target-connection', $TargetConnection
        '--schema', (Resolve-Path $SchemaPath).Path
        '--json'  # Always use JSON for progress parsing
    )

    if ($BatchSize -ne 1000) {
        $cliArgs += '--batch-size'
        $cliArgs += $BatchSize
    }

    if ($BypassPlugins) {
        $cliArgs += '--bypass-plugins'
    }

    # Build redacted args for logging (protect credentials)
    $redactedArgs = $cliArgs.Clone()
    for ($i = 0; $i -lt $redactedArgs.Count; $i++) {
        if ($redactedArgs[$i] -in @('--source-connection', '--target-connection') -and ($i + 1) -lt $redactedArgs.Count) {
            $redactedArgs[$i + 1] = Get-RedactedConnectionString $redactedArgs[$i + 1]
        }
    }
    Write-Verbose "Executing: $cliPath $($redactedArgs -join ' ')"

    # Execute CLI and parse progress
    $migrationResult = [PSCustomObject]@{
        RecordsProcessed = 0
        RecordsFailed    = 0
        Duration         = [TimeSpan]::Zero
    }
    $errorOutput = @()
    $currentPhase = 'starting'

    & $cliPath @cliArgs 2>&1 | ForEach-Object {
        $line = $_

        # Check if it's a JSON progress line
        if ($line -match '^\s*\{') {
            try {
                $progress = $line | ConvertFrom-Json

                switch ($progress.phase) {
                    'analyzing' {
                        Write-Verbose $progress.message
                    }
                    'export' {
                        if ($currentPhase -ne 'export') {
                            $currentPhase = 'export'
                            Write-Verbose "Starting export phase..."
                        }

                        if ($progress.entity -and $progress.total -gt 0) {
                            $percent = [math]::Min(100, [math]::Round(($progress.current / $progress.total) * 100))
                            $status = "$($progress.current)/$($progress.total)"
                            if ($progress.rps) {
                                $status += " @ $([math]::Round($progress.rps, 1)) rps"
                            }
                            Write-Progress -Activity "Exporting $($progress.entity)" `
                                -PercentComplete $percent `
                                -Status $status `
                                -Id 1
                        }
                        elseif ($progress.message) {
                            Write-Verbose $progress.message
                        }
                    }
                    'import' {
                        if ($currentPhase -ne 'import') {
                            $currentPhase = 'import'
                            Write-Progress -Activity "Export complete" -Completed -Id 1
                            Write-Verbose "Starting import phase..."
                        }

                        if ($progress.entity -and $progress.total -gt 0) {
                            $percent = [math]::Min(100, [math]::Round(($progress.current / $progress.total) * 100))
                            $status = "$($progress.current)/$($progress.total)"
                            if ($progress.rps) {
                                $status += " @ $([math]::Round($progress.rps, 1)) rps"
                            }
                            Write-Progress -Activity "Importing $($progress.entity)" `
                                -PercentComplete $percent `
                                -Status $status `
                                -Id 2
                        }
                        elseif ($progress.message) {
                            Write-Verbose $progress.message
                        }
                    }
                    'deferred' {
                        if ($progress.entity -and $progress.field) {
                            $percent = [math]::Min(100, [math]::Round(($progress.current / $progress.total) * 100))
                            Write-Progress -Activity "Updating deferred: $($progress.entity).$($progress.field)" `
                                -PercentComplete $percent `
                                -Status "$($progress.current)/$($progress.total)" `
                                -Id 2
                        }
                    }
                    'complete' {
                        Write-Progress -Activity "Migration complete" -Completed -Id 1
                        Write-Progress -Activity "Migration complete" -Completed -Id 2
                        $migrationResult.RecordsProcessed = $progress.recordsProcessed
                        $migrationResult.RecordsFailed = $progress.errors
                        if ($progress.duration) {
                            $migrationResult.Duration = [TimeSpan]::Parse($progress.duration)
                        }
                        Write-Verbose "Completed in $($progress.duration). Records: $($progress.recordsProcessed), Errors: $($progress.errors)"
                    }
                    'error' {
                        $errorOutput += $progress.message
                    }
                }
            }
            catch {
                # Not valid JSON, treat as regular output
                Write-Verbose $line
            }
        }
        else {
            # Regular output
            if ($line -is [System.Management.Automation.ErrorRecord]) {
                $errorOutput += $line.ToString()
            }
            else {
                Write-Verbose $line
            }
        }
    }

    # Check exit code
    if ($LASTEXITCODE -eq 2) {
        # Complete failure
        $errorMessage = if ($errorOutput.Count -gt 0) {
            $errorOutput -join "`n"
        }
        else {
            "Migration failed with exit code $LASTEXITCODE"
        }
        throw $errorMessage
    }
    elseif ($LASTEXITCODE -eq 1) {
        # Partial success
        Write-Warning "Migration completed with some failures. $($migrationResult.RecordsFailed) records failed."
    }

    Write-Progress -Activity "Migration" -Completed -Id 1
    Write-Progress -Activity "Migration" -Completed -Id 2

    if ($PassThru) {
        return $migrationResult
    }
}