Public/Initialize-ExpressionCache.ps1

<#
.SYNOPSIS
Initializes ExpressionCache and registers cache providers.
 
.DESCRIPTION
Initialize-ExpressionCache sets up module-wide state (e.g., AppName used in key prefixes) and
registers one or more providers. Each provider is a hashtable with a 'Key' and a 'Config'.
 
❗ CONFIGURATION SEMANTICS
- Replacement, not merge: when you pass a Config for a built-in or previously-registered provider,
  the supplied object becomes the provider’s **entire** Config. Defaults/previous values are NOT merged.
  Any omitted settings will be unset (or fall back to the provider’s own internal defaults if it has them).
- Re-initializing: calling Initialize-ExpressionCache again with the same provider Key updates that
  provider using the same replacement semantics.
 
Tip: If you want to modify just one or two settings while keeping the rest of the defaults, copy the
current config first (see examples) and then change the keys you care about.
 
.PARAMETER AppName
Application name used in key prefixes/namespacing.
 
.PARAMETER Providers
One or more provider definitions of the form:
@{ Key = '<ProviderName>'; Config = @{ ... provider settings ... } }
The supplied Config **replaces** any existing/default config for that provider (no merge).
 
.OUTPUTS
The list of registered provider objects.
 
.EXAMPLE
# Initialize with defaults only (LocalFileSystemCache)
Initialize-ExpressionCache -AppName 'MyApp'
 
.EXAMPLE
# Replace LocalFileSystemCache config entirely (no merge)
Initialize-ExpressionCache -AppName 'MyApp' -Providers @(
  @{ Key='LocalFileSystemCache'; Config = @{
        Prefix = 'ExpressionCache:v1:MyApp'
        CacheFolder = "$env:TEMP\ExpressionCache"
     } }
)
 
.EXAMPLE
# Preserve defaults but tweak one setting: copy then modify
$prov = Get-ExpressionCacheProvider -Name 'LocalFileSystemCache'
$cfg = [pscustomobject]@{} # shallow clone of current config
$prov.Config.PSObject.Properties | ForEach-Object {
  Add-Member -InputObject $cfg -MemberType NoteProperty -Name $_.Name -Value $_.Value
}
$cfg.Prefix = 'ExpressionCache:v1:MyApp'
Initialize-ExpressionCache -AppName 'MyApp' -Providers @(@{ Key='LocalFileSystemCache'; Config = $cfg })
 
.EXAMPLE
# Add/replace a Redis provider config (password via env var)
Initialize-ExpressionCache -AppName 'MyApp' -Providers @(
  @{ Key='Redis'; Config = @{
        Host = '127.0.0.1'
        Port = 6379
        Database = 2
        Prefix = 'ExpressionCache:v1:MyApp'
        Password = $env:EXPRCACHE_REDIS_PASSWORD
     } }
)
 
.LINK
Get-ExpressionCache
Get-ExpressionCacheProvider
New-ExpressionCacheKey
#>

function Initialize-ExpressionCache {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$AppName,

        [Parameter()]
        [object[]]$Providers
    )

    $script:Config = [pscustomobject]@{
        AppName = $AppName
        Version = $Script:moduleData.ModuleVersion
    }

    $script:RegisteredStorageProviders = @()

    $defaults = Get-DefaultProviders

    # Start with the module's standard providers (order matters)
    $selected = @(
        $defaults.LocalFileSystemCache
        $defaults.Redis
    )

    # If the caller passes overrides/replacements, apply them
    foreach ($hint in ($Providers | Where-Object { $_ })) {
        $resolved = Resolve-Provider -Hint $hint -DefaultMap $defaults

        # If same .Name exists, replace; else append
        $names = $selected | Select-Object -ExpandProperty Name
        $existingIndex = [array]::IndexOf($names, $resolved.Name)  # -1 if not found

        if ($existingIndex -ge 0) { 
            $selected[$existingIndex] = $resolved 
        }
        else { 
            $selected += $resolved 
        }
    }

    # Register them
    foreach ($p in $selected) {
        Add-ExpressionCacheProvider -Provider $p | Out-Null
    }

    return $script:RegisteredStorageProviders
}

function Get-DefaultProviders {
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Intentional plural for clarity: returns multiple providers.')]

    # TODO: Move this to localfilesystem init... (pass appname)
    $defaultCacheFolder =
    if ($IsWindows) {
        Join-Path $Env:LOCALAPPDATA "ExpressionCache\$($script:Config.AppName)"
    }
    else {
        Join-Path $HOME ".cache/ExpressionCache/$($script:Config.AppName)"
    }

    # Return an ordered hashtable keyed by a short provider key
    [ordered]@{
        LocalFileSystemCache = [pscustomobject]@{
            Name        = 'LocalFileSystemCache'
            Description = 'Stores cached expressions in the local file system.'
            Config      = [pscustomobject]@{
                ProviderName  = 'LocalFileSystemCache'
                CacheVersion  = 1
                CacheFolder   = $defaultCacheFolder
                DefaultMaxAge = (New-TimeSpan -Days 1)
                Initialized   = $false
            }

            GetOrCreate = 'Get-LocalFileSystem-CachedValue'
            Initialize  = 'Initialize-LocalFileSystem-Cache'
            ClearCache  = 'Clear-LocalFileSystem-Cache'
        }

        Redis                = [pscustomobject]@{
            Name        = 'redis-default'
            Config      = [pscustomobject]@{
                ProviderName  = 'redis-default' # used as key
                Host          = '127.0.0.1'
                Port          = 6379
                Database      = 2
                DefaultMaxAge = (New-TimeSpan -Days 1)
                Prefix        = "ExpressionCache:v$($Script:moduleData.ModuleVersion.Major):$($script:Config.AppName)"
                Password      = $env:EXPRCACHE_REDIS_PASSWORD ?? 'ChangeThisPassword!'
            }

            GetOrCreate = 'Get-Redis-CachedValue'
            Initialize  = 'Initialize-Redis-Cache'
            ClearCache  = 'Clear-Redis-Cache'
        }
    }
}

function Resolve-Provider {
    param(
        [Parameter(Mandatory)] 
        $Hint,

        [Parameter(Mandatory)] 
        $DefaultMap  # from Get-DefaultProviders
    )

    switch ($Hint.GetType().FullName) {
        'System.String' {
            # Treat as a default key alias
            if ($DefaultMap.Contains($Hint)) {
                return $DefaultMap[$Hint]
            }
            # Allow matching by provider Name too
            $byName = $DefaultMap.Values | Where-Object { $_.Name -eq $Hint }

            if ($byName) { 
                return $byName 
            }

            throw "Unknown provider key or name '$Hint'."
        }

        default {
            # If it looks like a full provider, use it as-is
            if ($Hint.PSObject.Properties.Name -contains 'GetOrCreate' -and $Hint.PSObject.Properties.Name -contains 'Initialize') {
                return $Hint
            }

            # Otherwise treat it as an override onto a default
            # Prefer 'Key' to pick the default entry; fallback to Name matching
            $key = $Hint.Key
            $base =
            if ($key -and $DefaultMap.Contains($key)) {
                $DefaultMap[$key]
            } 
            elseif ($Hint.Name) {
                $DefaultMap.Values | Where-Object { $_.Name -eq $Hint.Name } | Select-Object -First 1
            }

            if (-not $base) { 
                throw "Could not match override to a default provider (Key/Name missing or unknown)." 
            }

            return (Merge-ObjectDeep $base $Hint)
        }
    }
}


function Merge-ObjectDeep {
    param(
        [Parameter(Mandatory)] 
        $Base,
        
        [Parameter(Mandatory)] 
        $Override
    )

    if ($null -eq $Override) { 
        return $Base 
    }

    # For hashtables and PSCustomObjects, walk properties
    if ($Base -is [hashtable] -or $Base -is [pscustomobject]) {

        $result = if ($Base -is [hashtable]) { 
            @{} 
        } 
        else { 
            [pscustomobject]@{} 
        }

        $allKeys = @(if ($Base -is [hashtable]) { $Base.Keys } else { $Base.PSObject.Properties.Name }) + @(if ($Override -is [hashtable]) { $Override.Keys } else { $Override.PSObject.Properties.Name }) | Select-Object -Unique

        foreach ($k in $allKeys) {
            $b = if ($Base -is [hashtable]) { 
                $Base[$k] 
            } 
            else { 
                $Base.$k 
            }

            $o = if ($Override -is [hashtable]) { 
                $Override[$k] 
            } 
            else { 
                $Override.$k 
            }

            if ($null -ne $o -and ($b -is [hashtable] -or $b -is [pscustomobject])) {
                $result | Add-Member -NotePropertyName $k -NotePropertyValue (Merge-ObjectDeep $b $o)
            } 
            elseif ($null -ne $o) {
                $result | Add-Member -NotePropertyName $k -NotePropertyValue $o
            } 
            else {
                $result | Add-Member -NotePropertyName $k -NotePropertyValue $b
            }
        }
        return $result
    }

    # For scalars/arrays: override wins if provided, else base
    if ($null -ne $Override) { 
        return $Override 
    } 
    else { 
        return $Base 
    }
}