Providers/LocalFileSystem.ps1

function Initialize-LocalFileSystem-Cache {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$CacheFolder,

        [Parameter(Mandatory)] 
        [string]$ProviderName,
        
        [Parameter(Mandatory)]
        [string]$CacheVersion,

        [Parameter(Mandatory)]
        [timespan]$DefaultMaxAge
    )

    Test-Directory $CacheFolder
}

function Clear-LocalFileSystem-Cache {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$ProviderName,
        [switch]$Force
    )

    $provider = Get-ExpressionCacheProvider -ProviderName $ProviderName
    $folder = $provider.Config.CacheFolder

    if (-not $folder) { 
        return 
    }

    # Block same-process readers/writers while clearing
    With-ProviderLock $provider {
        if (Test-Path -LiteralPath $folder) {
            Remove-Item -LiteralPath $folder -Recurse -Force -ErrorAction SilentlyContinue
        }
    }
}


function Update-LocalFileSystem-Cache {
    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Low')]
    param(
        [Parameter(Mandatory)]
        [string]$Key,

        [Parameter(Mandatory)]
        $Data,

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

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

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

        [int]$JsonDepth = 10
    )

    if ($null -eq $Data -or (($Data -is [System.Collections.ICollection]) -and $Data.Count -eq 0)) { 
        return 
    }

    $cacheFile = [IO.Path]::Combine($CacheFolder, "$Key.txt")

    if ($PSCmdlet.ShouldProcess($cacheFile, 'Write cache entry')) {
        $payload = [pscustomobject]@{
            Version = $CacheVersion
            Query   = $Query
            Data    = $Data
        }

        Write-JsonFileAtomically -Path $cacheFile -Object $payload -JsonDepth $JsonDepth
    }
}

function Get-FromLocalFileSystem {
    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Low')]
    param(
        [Parameter(Mandatory)]
        [string]$Key,

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

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

        [Parameter(Mandatory)]
        [CachePolicy]$Policy
    )

    $cacheFile = [IO.Path]::Combine($CacheFolder, "$Key.txt")

    if (-not (Test-Path -LiteralPath $cacheFile)) { 
        return $null 
    }

    # Avoid TOCTOU by handling races in try/catch
    try {
        $item = Get-Item -LiteralPath $cacheFile -Force -ErrorAction Stop
        $lastWriteUtc = $item.LastWriteTimeUtc
        $nowUtc = (Get-Date).ToUniversalTime()

        $isFresh = switch ($Policy.Mode) {
            'Absolute' { $nowUtc -le $Policy.ExpireAtUtc }
            'Sliding' { ($nowUtc - $lastWriteUtc) -le [TimeSpan]::FromSeconds($Policy.TtlSeconds) }
            default { ($nowUtc - $lastWriteUtc) -le [TimeSpan]::FromSeconds($Policy.TtlSeconds) }
        }

        if (-not $isFresh) {
            if ($PSCmdlet.ShouldProcess($cacheFile, "Remove expired cache")) {
                Remove-Item -LiteralPath $cacheFile -Force -ErrorAction SilentlyContinue
            }

            return $null
        }

        $cacheContent = Read-JsonFileWithRetries -Path $cacheFile

        if ($cacheContent.Version -ne $CacheVersion) {
            Write-Verbose "LocalFileSystemCache: version mismatch: expected $CacheVersion, got $($cacheContent.Version)"

            return $null
        }

        if ($Policy.Sliding) {
            try { 
                [IO.File]::SetLastWriteTimeUtc($cacheFile, (Get-Date).ToUniversalTime()) 
            }
            catch { 

            }
        }

        return $cacheContent.Data
    }
    catch {
        Write-Warning "LocalFileSystemCache: failed to read/parse cache file: $cacheFile ($($_.Exception.Message))"
        return $null
    }
}

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

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

        [Parameter(Mandatory)]
        [scriptblock]$ScriptBlock,

        [object[]]$Arguments,

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

        [Parameter(Mandatory)]
        [CachePolicy]$Policy,

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

        [int]$WaitSeconds = 10,

        [int]$JsonDepth = 10
    )

    $response = Get-FromLocalFileSystem -Key $Key -CacheFolder $CacheFolder -CacheVersion $CacheVersion -Policy $Policy
    if ($null -ne $response) { 
        Write-Verbose "LocalFileSystemCache: Retrieved from cache: $Key"; return $response 
    }

    # Single-flight gate for this key (avoid duplicate compute+writes)
    $gateKey = "lfs::$CacheFolder::$Key"
    $gate = Get-KeyGate -Key $gateKey
    $ts = [TimeSpan]::FromSeconds([Math]::Max(1, $WaitSeconds))

    if (-not $gate.Wait($ts)) { 
        throw "Timeout acquiring cache gate for '$Key' after $WaitSeconds s." 
    }

    try {
        # Re-check after acquiring the gate (another thread may have populated it)
        $response = Get-FromLocalFileSystem -Key $Key -CacheFolder $CacheFolder -CacheVersion $CacheVersion -Policy $Policy
        if ($null -ne $response) { 
            return $response 
        }

        # MISS → compute
        if ($null -eq $Arguments) { $Arguments = @() }
        $response = & $ScriptBlock @Arguments

        if ($null -eq $response) {
            Write-Verbose "LocalFileSystemCache: Computed null; skipping write for: $Key"

            return $null
        }

        $desc = ($ScriptBlock.ToString() -split "`r?`n" | ForEach-Object { $_.Trim() }) -join ' '
        Update-LocalFileSystem-Cache -Key $Key -Data $response -Query $desc -CacheFolder $CacheFolder -CacheVersion $CacheVersion -JsonDepth $JsonDepth

        return $response
    }
    finally {
        $gate.Release() | Out-Null
    }
}

function Test-Directory {
    param(
        [Parameter(Mandatory)]
        [string]$Path
    )

    [void][IO.Directory]::CreateDirectory($Path)
}

# Write JSON atomically: temp -> (replace|move)
function Write-JsonFileAtomically {
    param(
        [Parameter(Mandatory)]
        [string]$Path,

        [Parameter(Mandatory)]
        $Object,

        [int]$JsonDepth = 10
    )

    $dir = Split-Path -Parent $Path
    Test-Directory $dir

    $json = $Object | ConvertTo-Json -Depth $JsonDepth
    if ($json -match '"System\.[^"]+"') {
        Write-Warning "ExpressionCache: ConvertTo-Json may have truncated objects at depth $JsonDepth. Consider increasing JsonDepth in provider config."
    }
    $tmp = Join-Path $dir (".tmp_{0}_{1}.json" -f $PID, [Guid]::NewGuid().ToString('N'))

    [IO.File]::WriteAllText($tmp, $json, (New-Object Text.UTF8Encoding($false)))

    # Atomic overwrite (or create) with retry for cross-process contention
    $maxRetries = 3
    for ($attempt = 0; $attempt -le $maxRetries; $attempt++) {
        try {
            # .NET Framework (PS 5.1) File.Move lacks overwrite param; delete first
            if ([IO.File]::Exists($Path)) { [IO.File]::Delete($Path) }
            [IO.File]::Move($tmp, $Path)
            return
        }
        catch [System.IO.IOException], [System.UnauthorizedAccessException] {
            if ($attempt -eq $maxRetries) {
                # Clean up orphaned temp file before re-throwing
                if ([IO.File]::Exists($tmp)) {
                    try { [IO.File]::Delete($tmp) } catch { }
                }
                throw
            }
            [System.Threading.Thread]::Sleep(25 * ($attempt + 1))
        }
    }
}

function Read-JsonFileWithRetries {
    param(
        [Parameter(Mandatory)]
        [string]$Path,

        [int]$Retries = 3,

        [int]$DelayMs = 25
    )
    for ($i = 0; $i -le $Retries; $i++) {
        try {
            $raw = [IO.File]::ReadAllText($Path, (New-Object Text.UTF8Encoding($false)))
            return $raw | ConvertFrom-Json
        } 
        catch [System.IO.FileNotFoundException] {
            if ($i -eq $Retries) { 
                throw 
            }
            Start-Sleep -Milliseconds $DelayMs

        } 
        catch [System.IO.IOException] {
            if ($i -eq $Retries) { 
                throw 
            }
            Start-Sleep -Milliseconds $DelayMs
        }
    }
}