Private/Get-CollectionCache.ps1

function Get-CollectionCacheKey {
    <#
    .SYNOPSIS
        Deterministic cache key for a Phase 1 collection: SHA-256 over every input
        that changes what is collected or how it is priced, plus the module version
        so a cache written by an older module shape is never reused.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$SubscriptionId,
        [Parameter(Mandatory)][string]$ResourceGroup,
        [Parameter(Mandatory)][string]$WorkspaceName,
        [int]$DaysBack = 90,
        [int]$DetectionLookbackDays = 90,
        [bool]$IncludeDefenderXDR = $false,
        [bool]$IncludeDetectionAnalyzer = $false,
        [decimal]$PricePerGB = 5.59,
        [decimal]$BasicPricePerGB = 1.15,
        [decimal]$LakePricePerGB = 0.20,
        [string]$ModuleVersion = ''
    )

    $inv = [cultureinfo]::InvariantCulture
    $material = (@(
        $SubscriptionId.ToLowerInvariant()
        $ResourceGroup.ToLowerInvariant()
        $WorkspaceName.ToLowerInvariant()
        "days=$DaysBack"
        "detdays=$DetectionLookbackDays"
        "xdr=$IncludeDefenderXDR"
        "da=$IncludeDetectionAnalyzer"
        "price=$($PricePerGB.ToString($inv))"
        "basic=$($BasicPricePerGB.ToString($inv))"
        "lake=$($LakePricePerGB.ToString($inv))"
        "v=$ModuleVersion"
    ) -join '|')

    $sha = [System.Security.Cryptography.SHA256]::Create()
    try {
        $hash = $sha.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($material))
        ([System.BitConverter]::ToString($hash) -replace '-', '').ToLowerInvariant()
    }
    finally { $sha.Dispose() }
}

function Get-CollectionCachePath {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Key,
        [string]$CachePath
    )

    if ([string]::IsNullOrWhiteSpace($CachePath)) {
        $base = if ($env:LOCALAPPDATA) { $env:LOCALAPPDATA } elseif ($env:XDG_CACHE_HOME) { $env:XDG_CACHE_HOME } else { Join-Path $HOME '.cache' }
        $CachePath = Join-Path $base 'LogHorizon\cache'
    }
    Join-Path $CachePath "collection-$Key.clixml"
}

function Get-CollectionCache {
    <#
    .SYNOPSIS
        Returns a cached Phase 1 collection when a fresh enough entry exists.
    .OUTPUTS
        PSCustomObject with Data (the cached collection), SavedAt, AgeMinutes and
        Path, or $null on a miss (absent, expired, unreadable, wrong shape).
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Key,
        [string]$CachePath,
        [int]$MaxAgeMinutes = 60
    )

    $file = Get-CollectionCachePath -Key $Key -CachePath $CachePath
    if (-not (Test-Path -LiteralPath $file -PathType Leaf)) {
        Write-Verbose "Cache miss (no file): $file"
        return $null
    }

    try {
        $envelope = Import-Clixml -LiteralPath $file
    }
    catch {
        Write-Warning "Cache file could not be read and will be ignored: $file ($($_.Exception.Message))"
        return $null
    }

    if (-not $envelope -or -not $envelope.PSObject.Properties['SavedAt'] -or -not $envelope.PSObject.Properties['Data'] -or $envelope.Key -ne $Key) {
        Write-Verbose "Cache miss (unexpected shape): $file"
        return $null
    }

    $savedAt = [datetime]$envelope.SavedAt
    $age = ((Get-Date).ToUniversalTime() - $savedAt.ToUniversalTime()).TotalMinutes
    if ($age -gt $MaxAgeMinutes) {
        Write-Verbose "Cache miss (expired, $([math]::Round($age)) min old): $file"
        return $null
    }

    [PSCustomObject]@{
        Data       = $envelope.Data
        SavedAt    = $savedAt
        AgeMinutes = [math]::Round($age, 1)
        Path       = $file
        Version    = $envelope.Version
    }
}

function Save-CollectionCache {
    <#
    .SYNOPSIS
        Persists a Phase 1 collection with Export-Clixml. The Context (which
        carries tokens) is never written; any property whose name ends in Token
        is stripped defensively as well.
    .OUTPUTS
        The cache file path.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Key,
        [Parameter(Mandatory)][object]$Data,
        [string]$CachePath,
        [string]$Version,
        [int]$MaxAgeMinutes = 60
    )

    $file = Get-CollectionCachePath -Key $Key -CachePath $CachePath
    $dir = Split-Path -Path $file -Parent
    if (-not (Test-Path -LiteralPath $dir -PathType Container)) {
        New-Item -ItemType Directory -Path $dir -Force | Out-Null
    }

    $safe = [ordered]@{}
    foreach ($p in $Data.PSObject.Properties) {
        if ($p.Name -eq 'Context') { continue }
        if ($p.Name -match '(?i)token$') { continue }
        $safe[$p.Name] = $p.Value
    }

    $envelope = [PSCustomObject]@{
        Key     = $Key
        SavedAt = (Get-Date).ToUniversalTime().ToString('o')
        Version = $Version
        Data    = [PSCustomObject]$safe
    }
    $envelope | Export-Clixml -LiteralPath $file -Depth 12 -Force
    $null = Remove-ExpiredCollectionCache -CachePath $dir -MaxAgeMinutes $MaxAgeMinutes -Keep $file
    $file
}

function Remove-ExpiredCollectionCache {
    <#
    .SYNOPSIS
        Deletes collection cache files older than MaxAgeMinutes so entries for
        other parameter sets or older module versions do not accumulate.
    .OUTPUTS
        The paths removed.
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory)][string]$CachePath,
        [int]$MaxAgeMinutes = 60,
        [string]$Keep
    )

    if (-not (Test-Path -LiteralPath $CachePath -PathType Container)) { return @() }
    $cutoff = (Get-Date).AddMinutes(-$MaxAgeMinutes)
    $removed = [System.Collections.Generic.List[string]]::new()
    foreach ($f in Get-ChildItem -LiteralPath $CachePath -Filter 'collection-*.clixml' -File -ErrorAction SilentlyContinue) {
        if ($Keep -and $f.FullName -eq $Keep) { continue }
        if ($f.LastWriteTime -ge $cutoff) { continue }
        if ($PSCmdlet.ShouldProcess($f.FullName, 'Remove expired cache')) {
            try { Remove-Item -LiteralPath $f.FullName -Force -ErrorAction Stop; $removed.Add($f.FullName) }
            catch { Write-Verbose "Could not remove expired cache $($f.Name): $($_.Exception.Message)" }
        }
    }
    @($removed)
}