Private/DataStore.ps1
|
# PSSnips — Config/index persistence, file locking, and environment initialisation. function script:LoadCfg { if (-not $script:CfgDirty -and $null -ne $script:CfgCache) { return $script:CfgCache } # Start with defaults $cfg = @{} $script:Defaults.GetEnumerator() | ForEach-Object { $cfg[$_.Key] = $_.Value } # Layer 1: user config (~/.pssnips/config.json) if (Test-Path $script:CfgFile) { try { $raw = Get-Content $script:CfgFile -Raw -Encoding UTF8 -ErrorAction Stop if ($raw) { $loaded = $raw | ConvertFrom-Json -AsHashtable foreach ($k in $loaded.Keys) { $cfg[$k] = $loaded[$k] } } } catch { Write-Verbose "LoadCfg: user config error — $($_.Exception.Message)" } } # Layer 2: workspace config (.pssnips/config.json in cwd, or $env:PSSNIPS_WORKSPACE) if ($script:WorkspaceCfgFile -and (Test-Path $script:WorkspaceCfgFile)) { try { $raw = Get-Content $script:WorkspaceCfgFile -Raw -Encoding UTF8 -ErrorAction Stop if ($raw) { $wsLoaded = $raw | ConvertFrom-Json -AsHashtable foreach ($k in $wsLoaded.Keys) { $cfg[$k] = $wsLoaded[$k] } } } catch { Write-Verbose "LoadCfg: workspace config error — $($_.Exception.Message)" } } # Layer 3: environment variables (highest priority, override everything) foreach ($envKey in $script:EnvVarMap.Keys) { $envVal = [System.Environment]::GetEnvironmentVariable($envKey) if ($envVal) { $cfg[$script:EnvVarMap[$envKey]] = $envVal } } $script:CfgCache = $cfg $script:CfgDirty = $false return $cfg } function script:SaveCfg { <# .SYNOPSIS Writes config.json atomically using a temp-file + rename pattern and an advisory .lock file to prevent concurrent overwrites. .NOTES Acquires the target config file's .lock before writing. On lock timeout a Write-Warning is emitted and the write proceeds anyway so the caller is never left without a config. The temp file is always cleaned up even on error via try/finally. Use -Scope Workspace to write to the workspace config (.pssnips/config.json in the current directory, or the path set in $env:PSSNIPS_WORKSPACE). #> param( [hashtable]$Cfg, [string]$Scope = 'User' ) $targetFile = if ($Scope -eq 'Workspace') { if (-not $script:WorkspaceCfgFile) { Write-Warning "Workspace config path not initialised. Call script:InitEnv first." return } $wsDir = Split-Path $script:WorkspaceCfgFile -Parent if (-not (Test-Path $wsDir)) { New-Item -ItemType Directory -Path $wsDir -Force | Out-Null } $script:WorkspaceCfgFile } else { $script:CfgFile } $lockFile = "$targetFile.lock" $lock = script:AcquireLock -LockFile $lockFile try { $tmp = "$targetFile.tmp" $Cfg | ConvertTo-Json -Depth 5 | Set-Content -Path $tmp -Encoding UTF8 Move-Item -Path $tmp -Destination $targetFile -Force if ($Scope -eq 'User') { $script:CfgCache = $Cfg $script:CfgDirty = $false } } finally { script:ReleaseLock -Stream $lock -LockFile $lockFile if (Test-Path "$targetFile.tmp") { Remove-Item "$targetFile.tmp" -ErrorAction SilentlyContinue } } } function script:LoadIdx { if (-not $script:IdxDirty -and $null -ne $script:IdxCache) { return $script:IdxCache } if (Test-Path $script:IdxFile) { try { $raw = Get-Content $script:IdxFile -Raw -Encoding UTF8 -ErrorAction Stop if ($raw) { $idx = $raw | ConvertFrom-Json -AsHashtable # -AsHashtable preserves nested hashtables for index key access if (-not $idx.ContainsKey('snippets')) { $idx['snippets'] = @{} } # Convert raw hashtable entries to SnippetMetadata objects $converted = @{} foreach ($key in $idx['snippets'].Keys) { $converted[$key] = [SnippetMetadata]::FromHashtable($idx['snippets'][$key]) } $idx['snippets'] = $converted $script:IdxCache = $idx $script:IdxDirty = $false return $idx } } catch { Write-Verbose "LoadIdx: reinitialising index — $($_.Exception.Message)" } } $idx = @{ snippets = @{} } $script:IdxCache = $idx $script:IdxDirty = $false return $idx } function script:SaveIdx { <# .SYNOPSIS Writes index.json atomically using a temp-file + rename pattern and an advisory .lock file to prevent concurrent overwrites. .NOTES Acquires $script:IdxFile.lock (exclusive FileShare.None) before writing. On lock timeout a Write-Warning is emitted and the write proceeds anyway so the caller is never blocked indefinitely. The temp file is always cleaned up even on error via try/finally. For full read-modify-write atomicity, wrap the load+modify+save sequence in script:WithIdxLock { ... } at the call site. #> param([hashtable]$Idx) $lockFile = "$script:IdxFile.lock" $lock = script:AcquireLock -LockFile $lockFile try { $tmp = "$script:IdxFile.tmp" $serializable = @{ snippets = @{} } foreach ($key in $Idx.snippets.Keys) { $entry = $Idx.snippets[$key] $serializable.snippets[$key] = if ($entry -is [SnippetMetadata]) { $entry.ToHashtable() } else { $entry } } $serializable | ConvertTo-Json -Depth 10 | Set-Content -Path $tmp -Encoding UTF8 Move-Item -Path $tmp -Destination $script:IdxFile -Force $script:IdxCache = $Idx $script:IdxDirty = $false $script:CompleterCache = $null } finally { script:ReleaseLock -Stream $lock -LockFile $lockFile if (Test-Path "$script:IdxFile.tmp") { Remove-Item "$script:IdxFile.tmp" -ErrorAction SilentlyContinue } } } function script:AcquireLock { <# .SYNOPSIS Opens a .lock file with exclusive access. Returns a FileStream on success, or $null after TimeoutMs if the file is held by another process. .NOTES Works on local NTFS and UNC paths. Callers must pass the stream to script:ReleaseLock in a finally block. #> param( [string]$LockFile, [int]$TimeoutMs = $script:LockTimeoutMs, [int]$RetryMs = 50 ) $deadline = (Get-Date).AddMilliseconds($TimeoutMs) while ((Get-Date) -lt $deadline) { try { $stream = [System.IO.File]::Open( $LockFile, [System.IO.FileMode]::OpenOrCreate, [System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None) return $stream } catch [System.IO.IOException] { Start-Sleep -Milliseconds $RetryMs } } Write-Warning "PSSnips: could not acquire lock on '$LockFile' after ${TimeoutMs}ms — proceeding without lock." return $null } function script:ReleaseLock { <# .SYNOPSIS Closes and disposes a lock FileStream and removes the .lock file. #> param( [System.IO.FileStream]$Stream, [string]$LockFile ) if ($null -ne $Stream) { $Stream.Close() $Stream.Dispose() Remove-Item -Path $LockFile -ErrorAction SilentlyContinue } } function script:WithIdxLock { <# .SYNOPSIS Acquires the index advisory lock, runs a scriptblock, then releases the lock. Callers performing LoadIdx → modify → SaveIdx can wrap the sequence here for full read-modify-write atomicity. #> param([scriptblock]$Action) $lockFile = "$script:IdxFile.lock" $lock = script:AcquireLock -LockFile $lockFile try { & $Action } finally { script:ReleaseLock -Stream $lock -LockFile $lockFile } } function script:InitEnv { $script:FtsCacheFile = Join-Path $script:Home 'fts-cache.json' # Resolve workspace config file path (env override or cwd/.pssnips/config.json) $wsDir = if ($env:PSSNIPS_WORKSPACE) { $env:PSSNIPS_WORKSPACE } else { Join-Path (Get-Location) '.pssnips' } $script:WorkspaceCfgFile = Join-Path $wsDir 'config.json' script:EnsureDirs # Clean up any stale .lock files left by a previous crashed session. Get-Item "$script:SnipDir\*.lock", "$script:IdxFile.lock", "$script:CfgFile.lock" ` -ErrorAction SilentlyContinue | Remove-Item -ErrorAction SilentlyContinue if (-not (Test-Path $script:CfgFile)) { $def = @{}; $script:Defaults.GetEnumerator() | ForEach-Object { $def[$_.Key] = $_.Value } script:SaveCfg -Cfg $def } if (-not (Test-Path $script:IdxFile)) { script:SaveIdx -Idx @{ snippets = @{} } } if ($null -eq $script:Repository) { $script:Repository = [JsonSnipRepository]::new($script:Home) } script:InvalidateCache } function script:InvalidateCache { $script:IdxDirty = $true $script:CfgDirty = $true $script:CompleterCache = $null $script:FtsCache = $null # Directly set dirty flags on repository to avoid circular call if ($null -ne $script:Repository) { $script:Repository._idxDirty = $true $script:Repository._cfgDirty = $true } } # Delegate scriptblocks: allow JsonSnipRepository class methods to call script: functions $script:_LoadIdxDelegate = { script:LoadIdx } $script:_SaveIdxDelegate = { param($i) script:SaveIdx -Idx $i } $script:_LoadCfgDelegate = { script:LoadCfg } $script:_SaveCfgDelegate = { param($c, $s) script:SaveCfg -Cfg $c -Scope $s } $script:_InvalidateDelegate = { script:InvalidateCache } |