Functions/GenXdev.Data.KeyValueStore/Sync-KeyValueStore.ps1

<##############################################################################
Part of PowerShell module : GenXdev.Data.KeyValueStore
Original cmdlet filename : Sync-KeyValueStore.ps1
Original author : René Vaessen / GenXdev
Version : 1.298.2025
################################################################################
MIT License
 
Copyright 2021-2025 GenXdev
 
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
 
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
 
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
################################################################################>

###############################################################################
<#
.SYNOPSIS
Synchronizes local and OneDrive key-value store JSON files.
 
.DESCRIPTION
Performs two-way synchronization between local and OneDrive shadow JSON files using
a last-modified timestamp strategy. Records are merged based on their last
modification time, with newer versions taking precedence.
 
.PARAMETER SynchronizationKey
Identifies the synchronization scope for the operation. Using "Local" will skip
synchronization as it indicates local-only records.
 
.PARAMETER DatabasePath
Database path for key-value store data files.
 
.EXAMPLE
Sync-KeyValueStore
 
.EXAMPLE
Sync-KeyValueStore -SynchronizationKey "UserSettings"
#>

function Sync-KeyValueStore {

    [CmdletBinding()]
    param(
        ###############################################################################
        [Parameter(
            Mandatory = $false,
            Position = 0,
            HelpMessage = 'Key to identify synchronization scope'
        )]
        [string] $SynchronizationKey = 'Local',
        ###############################################################################
        [Parameter(
            Mandatory = $false,
            HelpMessage = 'Database path for key-value store data files'
        )]
        [string] $DatabasePath
        ###############################################################################
    )

    begin {

        # check if custom base path was provided or use default location
        if ([string]::IsNullOrWhiteSpace($DatabasePath)) {

            # construct default base path in local app data folder
            $basePath = "$($ENV:LOCALAPPDATA)\GenXdev.PowerShell\KeyValueStore"
        }
        else {

            # use the provided base path
            $basePath = $DatabasePath
        }

        # construct path to onedrive shadow directory for synchronization
        $shadowPath = GenXdev.FileSystem\Expand-Path `
            "~\OneDrive\GenXdev.PowerShell.SyncObjects\KeyValueStore"

        # log the beginning of sync operation for troubleshooting
        Microsoft.PowerShell.Utility\Write-Verbose `
            "Starting key-value store sync with key: $SynchronizationKey"
    }

    process {

        # skip synchronization for local-only records to avoid unnecessary work
        if ($SynchronizationKey -eq 'Local') {

            # inform user that local-only sync is being skipped
            Microsoft.PowerShell.Utility\Write-Verbose `
                'Skipping sync for local-only key'
            return
        }

        # log store directory paths for debugging and verification purposes
        Microsoft.PowerShell.Utility\Write-Verbose "Local path: $basePath"
        Microsoft.PowerShell.Utility\Write-Verbose "Shadow path: $shadowPath"

        # verify both directories exist before attempting synchronization
        if (-not ([System.IO.Directory]::Exists($basePath) -and
                [System.IO.Directory]::Exists($shadowPath))) {

            # inform user that missing directories are being initialized
            Microsoft.PowerShell.Utility\Write-Verbose `
                'Initializing missing store directories'

            # copy compatible parameters for the initialization function call
            $params = GenXdev.FileSystem\Copy-IdenticalParamValues `
                -BoundParameters $PSBoundParameters `
                -FunctionName 'GenXdev.Data\Initialize-KeyValueStores' `
                -DefaultValues (Microsoft.PowerShell.Utility\Get-Variable `
                    -Scope Local `
                    -ErrorAction SilentlyContinue)

            # initialize the key-value store directories if they don't exist
            GenXdev.Data\Initialize-KeyValueStores @params
        }

        # get all JSON files from both directories matching the sync key pattern
        $safeSyncKey = $SynchronizationKey -replace '[\\/:*?"<>|]', '_'
        $filePattern = "${safeSyncKey}_*.json"

        Microsoft.PowerShell.Utility\Write-Verbose `
            "Syncing files matching pattern: $filePattern"

        # collect all matching store files from both locations
        $localFiles = @{}
        $shadowFiles = @{}

        foreach ($file in (Microsoft.PowerShell.Management\Get-ChildItem `
                    -LiteralPath $basePath `
                    -Filter $filePattern `
                    -File `
                    -ErrorAction SilentlyContinue)) {
            $localFiles[$file.Name] = $file.FullName
        }

        foreach ($file in (Microsoft.PowerShell.Management\Get-ChildItem `
                    -LiteralPath $shadowPath `
                    -Filter $filePattern `
                    -File `
                    -ErrorAction SilentlyContinue)) {
            $shadowFiles[$file.Name] = $file.FullName
        }

        # get union of all filenames
        $allFilenames = $localFiles.Keys + $shadowFiles.Keys | `
            Microsoft.PowerShell.Utility\Select-Object -Unique

        # sync each store file
        foreach ($filename in $allFilenames) {
            Microsoft.PowerShell.Utility\Write-Verbose `
                "Syncing store file: $filename"

            $localFilePath = [System.IO.Path]::Combine($basePath, $filename)
            $shadowFilePath = [System.IO.Path]::Combine($shadowPath, $filename)

            # read both store versions
            $localData = GenXdev.FileSystem\ReadJsonWithRetry -FilePath $localFilePath
            $shadowData = GenXdev.FileSystem\ReadJsonWithRetry -FilePath $shadowFilePath

            # merge stores based on last modified timestamps
            $mergedData = @{}

            # add all local keys
            foreach ($key in $localData.Keys) {
                $mergedData[$key] = $localData[$key]
            }

            # merge shadow keys, keeping newer versions
            foreach ($key in $shadowData.Keys) {
                $shadowEntry = $shadowData[$key]

                if ($mergedData.ContainsKey($key)) {
                    $localEntry = $mergedData[$key]

                    # compare timestamps if both have metadata
                    if ($localEntry -is [hashtable] -and
                        $shadowEntry -is [hashtable] -and
                        $localEntry.ContainsKey('lastModified') -and
                        $shadowEntry.ContainsKey('lastModified')) {

                        $localTime = [DateTime]::Parse($localEntry['lastModified'])
                        $shadowTime = [DateTime]::Parse($shadowEntry['lastModified'])

                        # keep newer version
                        if ($shadowTime -gt $localTime) {
                            $mergedData[$key] = $shadowEntry
                        }
                    }
                    else {
                        # no metadata, keep shadow version
                        $mergedData[$key] = $shadowEntry
                    }
                }
                else {
                    # key only exists in shadow, add it
                    $mergedData[$key] = $shadowEntry
                }
            }

            # write merged data to both locations
            GenXdev.FileSystem\WriteJsonAtomic -FilePath $localFilePath -Data $mergedData
            GenXdev.FileSystem\WriteJsonAtomic -FilePath $shadowFilePath -Data $mergedData
        }
    }

    end {

        # log completion of sync operation for audit and troubleshooting
        Microsoft.PowerShell.Utility\Write-Verbose 'Sync operation completed'
    }
}