src/Client/Sync-XrmRecords.ps1

<#
    .SYNOPSIS
    Synchronize records between two Dataverse instances.

    .DESCRIPTION
    Reads records from a source instance, transforms attributes according to sync options,
    then upserts records in a target instance. Supports optional two-pass dependency sync.

    .PARAMETER SourceXrmClient
    Source Dataverse connector.

    .PARAMETER TargetXrmClient
    Target Dataverse connector.

    .PARAMETER LogicalNames
    Entity logical names to synchronize.

    .PARAMETER Columns
    Columns to retrieve from source entities. Default: *

    .PARAMETER ExcludedAttributes
    Attribute logical names excluded from synchronization.

    .PARAMETER IncludeEntityReferences
    Include EntityReference attributes in synchronization.

    .PARAMETER TwoPassDependencies
    Run sync in 2 passes. First pass excludes EntityReference attributes,
    second pass includes them.

    .PARAMETER PreserveCreatedOn
    Preserve source createdon value using overriddencreatedon when available.

    .PARAMETER StateHandling
    Controls state/status handling after upsert:
    - Ignore
    - ApplyStateCode
    - ApplyStateAndStatus

    .PARAMETER BypassCustomPluginExecution
    Bypass custom plugin execution during upsert.

    .PARAMETER ContinueOnError
    Continue processing records when one record fails.

    .PARAMETER TopCount
    Limit source record retrieval.

    .PARAMETER OrderByField
    Optional order field applied to source query.

    .PARAMETER OrderType
    Query order direction when OrderByField is provided.

    .OUTPUTS
    PSCustomObject array.

    .EXAMPLE
    Sync-XrmRecords -SourceXrmClient $source -TargetXrmClient $target -LogicalNames @("account") -Columns @("name");
#>

function Sync-XrmRecords {
    [CmdletBinding()]
    [OutputType([PSCustomObject[]])]
    param
    (
        [Parameter(Mandatory = $true)]
        [Microsoft.PowerPlatform.Dataverse.Client.ServiceClient]
        $SourceXrmClient,

        [Parameter(Mandatory = $true)]
        [Microsoft.PowerPlatform.Dataverse.Client.ServiceClient]
        $TargetXrmClient,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [String[]]
        $LogicalNames,

        [Parameter(Mandatory = $false)]
        [String[]]
        $Columns = @("*"),

        [Parameter(Mandatory = $false)]
        [String[]]
        $ExcludedAttributes = @(
            "createdon",
            "modifiedon",
            "createdby",
            "modifiedby",
            "createdonbehalfby",
            "modifiedonbehalfby",
            "ownerid",
            "owningbusinessunit",
            "owninguser",
            "owningteam",
            "statecode",
            "statuscode",
            "transactioncurrencyid"
        ),

        [Parameter(Mandatory = $false)]
        [bool]
        $IncludeEntityReferences = $false,

        [Parameter(Mandatory = $false)]
        [bool]
        $TwoPassDependencies = $false,

        [Parameter(Mandatory = $false)]
        [bool]
        $PreserveCreatedOn = $true,

        [Parameter(Mandatory = $false)]
        [ValidateSet("Ignore", "ApplyStateCode", "ApplyStateAndStatus")]
        [String]
        $StateHandling = "Ignore",

        [Parameter(Mandatory = $false)]
        [switch]
        $BypassCustomPluginExecution = $false,

        [Parameter(Mandatory = $false)]
        [bool]
        $ContinueOnError = $true,

        [Parameter(Mandatory = $false)]
        [int]
        $TopCount,

        [Parameter(Mandatory = $false)]
        [String]
        $OrderByField,

        [Parameter(Mandatory = $false)]
        [Microsoft.Xrm.Sdk.Query.OrderType]
        $OrderType = [Microsoft.Xrm.Sdk.Query.OrderType]::Descending
    )
    begin {
        $StopWatch = [System.Diagnostics.Stopwatch]::StartNew();
        Trace-XrmFunction -Name $MyInvocation.MyCommand.Name -Stage Start -Parameters ($MyInvocation.MyCommand.Parameters);
    }
    process {

        [System.Collections.ArrayList]$summary = @();

        ForEach-ObjectWithProgress -Collection $LogicalNames -OperationName "Synchronizing entities" -ScriptBlock {
            param($logicalName)

            $entityStopWatch = [System.Diagnostics.Stopwatch]::StartNew();
            $readCount = 0;
            $upsertedCount = 0;
            $failedCount = 0;
            [System.Collections.ArrayList]$errorMessages = @();

            try {
                $metadata = $TargetXrmClient | Get-XrmEntityMetadata -LogicalName $logicalName -Filter ([Microsoft.Xrm.Sdk.Metadata.EntityFilters]::Attributes);
                $targetAttributeSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase);
                $updatableAttributeSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase);

                if ($metadata -and $metadata.Attributes) {
                    foreach ($attributeMetadata in $metadata.Attributes) {
                        if (-not $attributeMetadata.LogicalName) {
                            continue;
                        }

                        [void]$targetAttributeSet.Add($attributeMetadata.LogicalName);

                        $isValidForUpdate = $true;
                        if ($attributeMetadata.PSObject.Properties.Match("IsValidForUpdate").Count -gt 0) {
                            $validValue = $attributeMetadata.IsValidForUpdate;
                            if ($validValue -is [bool]) {
                                $isValidForUpdate = $validValue;
                            }
                            elseif ($validValue -and $validValue.PSObject.Properties.Match("Value").Count -gt 0) {
                                $isValidForUpdate = [bool]$validValue.Value;
                            }
                        }

                        if ($isValidForUpdate) {
                            [void]$updatableAttributeSet.Add($attributeMetadata.LogicalName);
                        }
                    }
                }

                $hasOverriddenCreatedOn = $targetAttributeSet.Contains("overriddencreatedon");
                $hasStateCode = $targetAttributeSet.Contains("statecode");
                $hasStatusCode = $targetAttributeSet.Contains("statuscode");

                $queryArguments = @{
                    "LogicalName" = $logicalName;
                    "Columns" = $Columns;
                };
                if ($PSBoundParameters.ContainsKey("TopCount")) {
                    $queryArguments["TopCount"] = $TopCount;
                }

                $query = New-XrmQueryExpression @queryArguments;
                if ($PSBoundParameters.ContainsKey("OrderByField")) {
                    $query = $query | Add-XrmQueryOrder -Field $OrderByField -OrderType $OrderType;
                }

                $sourceRecords = @($SourceXrmClient | Get-XrmMultipleRecords -Query $query);
                $readCount = $sourceRecords.Count;

                $passCount = 1;
                if ($TwoPassDependencies) {
                    $passCount = 2;
                }

                for ($passIndex = 1; $passIndex -le $passCount; $passIndex++) {
                    $includeEntityReferencesForPass = $IncludeEntityReferences;
                    if ($TwoPassDependencies) {
                        if ($passIndex -eq 1) {
                            $includeEntityReferencesForPass = $false;
                        }
                        else {
                            $includeEntityReferencesForPass = $true;
                        }
                    }

                    ForEach-ObjectWithProgress -Collection $sourceRecords -OperationName "Sync $logicalName (pass $passIndex/$passCount)" -ScriptBlock {
                        param($sourceRecord)

                        try {
                            $sourceEntity = $sourceRecord.Record;
                            $stateCodeValue = $null;
                            $statusCodeValue = $null;

                            $attributes = @{};
                            foreach ($sourceAttribute in $sourceEntity.Attributes.GetEnumerator()) {
                                $attributeName = [string]$sourceAttribute.Key;
                                $attributeValue = $sourceAttribute.Value;

                                if ($ExcludedAttributes -contains $attributeName) {
                                    continue;
                                }

                                if ($attributeName -eq "statecode") {
                                    if ($attributeValue) {
                                        $stateCodeValue = $attributeValue.Value;
                                    }
                                    continue;
                                }

                                if ($attributeName -eq "statuscode") {
                                    if ($attributeValue) {
                                        $statusCodeValue = $attributeValue.Value;
                                    }
                                    continue;
                                }

                                if ($attributeName -eq "createdon") {
                                    if ($PreserveCreatedOn -and $hasOverriddenCreatedOn) {
                                        if ($updatableAttributeSet.Contains("overriddencreatedon")) {
                                            $attributes["overriddencreatedon"] = $attributeValue;
                                        }
                                    }
                                    continue;
                                }

                                if ($attributeValue -is [Microsoft.Xrm.Sdk.EntityReference] -and -not $includeEntityReferencesForPass) {
                                    continue;
                                }

                                if (-not $targetAttributeSet.Contains($attributeName)) {
                                    continue;
                                }

                                if (-not $updatableAttributeSet.Contains($attributeName)) {
                                    continue;
                                }

                                $attributes[$attributeName] = $attributeValue;
                            }

                            $targetRecord = New-XrmEntity -LogicalName $logicalName -Id $sourceEntity.Id -Attributes $attributes;
                            $TargetXrmClient | Upsert-XrmRecord -Record $targetRecord -BypassCustomPluginExecution:$BypassCustomPluginExecution | Out-Null;

                            if ($passIndex -eq $passCount) {
                                $upsertedCount++;
                            }

                            if ($passIndex -eq $passCount -and $StateHandling -ne "Ignore" -and $hasStateCode) {
                                if ($StateHandling -eq "ApplyStateCode") {
                                    if ($null -ne $stateCodeValue) {
                                        $stateRecord = New-XrmEntity -LogicalName $logicalName -Id $sourceEntity.Id -Attributes @{
                                            "statecode" = New-XrmOptionSetValue -Value $stateCodeValue;
                                        };

                                        if ($hasStatusCode -and $null -ne $statusCodeValue) {
                                            $stateRecord.Attributes["statuscode"] = New-XrmOptionSetValue -Value $statusCodeValue;
                                        }

                                        $TargetXrmClient | Update-XrmRecord -Record $stateRecord | Out-Null;
                                    }
                                }
                                elseif ($StateHandling -eq "ApplyStateAndStatus") {
                                    if ($null -ne $stateCodeValue -and $null -ne $statusCodeValue -and $hasStatusCode) {
                                        $recordReference = New-XrmEntityReference -LogicalName $logicalName -Id $sourceEntity.Id;
                                        $TargetXrmClient | Set-XrmRecordState -RecordReference $recordReference -StateCode $stateCodeValue -StatusCode $statusCodeValue | Out-Null;
                                    }
                                }
                            }
                        }
                        catch {
                            $failedCount++;
                            $errorMessages.Add($_.Exception.Message) | Out-Null;
                            if (-not $ContinueOnError) {
                                throw $_.Exception;
                            }
                        }
                    };
                }
            }
            catch {
                $failedCount++;
                $errorMessages.Add($_.Exception.Message) | Out-Null;
                if (-not $ContinueOnError) {
                    throw $_.Exception;
                }
            }
            finally {
                $entityStopWatch.Stop();
                $summaryItem = [pscustomobject]@{
                    "LogicalName"   = $logicalName;
                    "ReadCount"     = $readCount;
                    "UpsertedCount" = $upsertedCount;
                    "FailedCount"   = $failedCount;
                    "Duration"      = $entityStopWatch.Elapsed;
                    "Errors"        = @($errorMessages);
                };
                $summary.Add($summaryItem) | Out-Null;
            }
        };

        $summary;
    }
    end {
        $StopWatch.Stop();
        Trace-XrmFunction -Name $MyInvocation.MyCommand.Name -Stage Stop -StopWatch $StopWatch;
    }
}

Export-ModuleMember -Function Sync-XrmRecords -Alias *;

Register-ArgumentCompleter -CommandName Sync-XrmRecords -ParameterName "LogicalNames" -ScriptBlock {

    param($CommandName, $ParameterName, $WordToComplete, $CommandAst, $FakeBoundParameters)

    $validLogicalNames = Get-XrmEntitiesLogicalName;
    return $validLogicalNames | Where-Object { $_ -like "$wordToComplete*" };
}