public/Get-BucketObject.ps1

function Get-BucketObject {
    <#
    .SYNOPSIS
    Retrieves objects from one or more buckets.
    .DESCRIPTION
    Reads serialized objects from bucket directories. When no bucket is specified,
    reads from the "default" bucket. Supports exact-match hashtable
    filtering (-Match) and arbitrary scriptblock filtering (-Filter).
    Retrieved objects include metadata properties: _BucketName, _BucketKey, _BucketFile.
    .PARAMETER Key
    Object key(s) to retrieve (Position 0). Accepts multiple values (e.g. -Key "alpha", "beta"). Case-insensitive prefix match for each key.
    .PARAMETER Bucket
    Bucket name(s) to search (Position 1). If omitted, reads from the "default" bucket. Supports wildcards.
    .PARAMETER Path
    Root directory for bucket storage. Default: $HOME/.buckets.
    .PARAMETER Match
    Hashtable of property-value pairs for exact-match filtering. All pairs must match. Supports $null values.
    .PARAMETER Filter
    ScriptBlock for custom filtering. Use $_ to reference object properties (e.g., { $_.Age -gt 30 }).
    .PARAMETER Recurse
    Recurse into nested sub-buckets. Without this switch, only returns objects from the specified bucket directory.
    .PARAMETER Depth
    Maximum nesting depth when recursing. Default: unlimited. Depth 1 returns objects from the root bucket only (same as no -Recurse). Depth 2 adds immediate sub-buckets.
    .PARAMETER All
    Search across all buckets. Implies recursion (all sub-buckets included) unless overridden by -Depth 1.
    Use with -Bucket to narrow which buckets to scan.
    .PARAMETER First
    Return only the first N objects.
    .PARAMETER Skip
    Skip the first N objects before returning results.
    .OUTPUTS
    Deserialized PSObjects with _BucketName, _BucketKey, and _BucketFile metadata.
    .EXAMPLE
    Get-BucketObject -Bucket users
    .EXAMPLE
    Get-BucketObject "Alice" users
    .EXAMPLE
    Get-BucketObject -Bucket users -Match @{ Role = "admin" }
    .EXAMPLE
    Get-BucketObject -Bucket users -Match @{ Deleted = $null }
    .EXAMPLE
    Get-BucketObject -Filter { $_.Status -eq "shipped" -and $_.Shipping.Method -eq "Express" }
    .EXAMPLE
    Get-BucketObject -Bucket users, orders
    .EXAMPLE
    Get-BucketObject -Bucket org
    .EXAMPLE
    Get-BucketObject -First 10 -Skip 20
    .EXAMPLE
    Get-BucketObject -All
    .EXAMPLE
    Get-BucketObject "config" -All
    .EXAMPLE
    Get-BucketObject -Bucket users -All
    #>

    [CmdletBinding()]
    param(
        [Parameter(Position = 0)][string[]]$Key,
        [string]$Path,
        [Parameter(Position = 1)][string[]]$Bucket,
        [hashtable]$Match,
        [scriptblock]$Filter,
        [switch]$Recurse,
        [int]$Depth = [int]::MaxValue,
        [int]$First,
        [int]$Skip,
        [object]$Funnel,
        [switch]$Expand,
        [switch]$All
    )

    if ([string]::IsNullOrWhiteSpace($Path)) { $Path = Get-DefaultPath }
    $Path = Resolve-SafePath -Path $Path

    $bucketPaths = @()
    if ($All) {
        $allBuckets = Get-Bucket -Path $Path -Recurse -Depth $Depth
        $selectedBuckets = $allBuckets
        if ($Bucket -and $Bucket.Count -gt 0) {
            $selectedBuckets = foreach ($b in $Bucket) {
                $allBuckets | Where-Object { if ($b -match '[\*\?]') { $_.Name -like $b } else { $_.Name -eq $b -or $_.Name -like "$b/*" } }
            }
        }
        if ($PSBoundParameters.ContainsKey('Depth') -and $Depth -eq 1) {
            $selectedBuckets = $selectedBuckets | Where-Object { $_.Name -notmatch '[/\\]' }
        }
        $bucketPaths = @($selectedBuckets | ForEach-Object { $_.Path })
    }
    elseif ($Bucket -and $Bucket.Count -gt 0) {
        $cachedBuckets = $null
        foreach ($b in $Bucket) {
            if ($b -match '[\*\?]') {
                if ($null -eq $cachedBuckets) { $cachedBuckets = Get-Bucket -Path $Path -Recurse:$Recurse -Depth $Depth }
                $matched = $cachedBuckets | Where-Object { $_.Name -like $b }
                $bucketPaths += $matched | ForEach-Object { $_.Path }
            }
            else {
                $bp = Get-BucketPath -Name $b -Path $Path
                $bucketPaths += $bp
                if ($Recurse) {
                    $nested = Get-Bucket -Path $Path -Recurse -Depth $Depth | Where-Object { $_.Name -like "$b/*" }
                    $bucketPaths += $nested | ForEach-Object { $_.Path }
                }
            }
        }
    }
    else {
        $bucketPaths += Get-BucketPath -Name "default" -Path $Path
    }

    $funnelDef = Resolve-Funnel $Funnel

    $allObjects = [System.Collections.ArrayList]::new()
    $warnedBuckets = @{}
    $firstLimit = if ($First -gt 0) { $First + [Math]::Max(0, $Skip) } else { 0 }
    $firstReached = $false

    :bucketLoop foreach ($bucketPath in $bucketPaths) {
        if ($firstReached) { break }
        if (-not [System.IO.Directory]::Exists($bucketPath)) {
            $bucketLeaf = Split-Path $bucketPath -Leaf
            if (-not $warnedBuckets.ContainsKey($bucketLeaf)) {
                Write-Warning "Bucket '$bucketLeaf' not found"
                $warnedBuckets[$bucketLeaf] = $true
            }
            continue
        }
        $rel = $bucketPath.Substring($Path.Length).TrimStart([System.IO.Path]::DirectorySeparatorChar)
        $bucketName = $rel -replace [regex]::Escape([System.IO.Path]::DirectorySeparatorChar), '/'

        if ($Expand -and ($null -eq $Key -or $Key.Count -eq 0)) {
            $reconstructed = Reconstruct-Object -DirPath $bucketPath
            if ($null -ne $reconstructed) {
                if ($Filter) {
                    if ($null -eq ($reconstructed | Where-Object $Filter)) { continue }
                }
                Add-HiddenProperty -Target $reconstructed -Name '_BucketName' -Value $bucketName
                Add-HiddenProperty -Target $reconstructed -Name '_BucketKey' -Value $null
                Add-HiddenProperty -Target $reconstructed -Name '_BucketFile' -Value $null
                $null = $allObjects.Add($reconstructed)
                if ($firstLimit -gt 0 -and $allObjects.Count -ge $firstLimit) { $firstReached = $true; break }
            }
        }
        else {
            $files = Get-ObjectFiles -BucketPath $bucketPath -Key $Key

            :fileLoop foreach ($file in $files) {
                if ($firstReached) { break }
                if ($null -eq $file -or -not [System.IO.File]::Exists($file.FullName)) { continue }
                $obj = Read-BucketFile -File $file
                if ($null -eq $obj) { continue }

                if ($Match -and -not (Test-MatchFilter -Object $obj -Match $Match)) { continue }

                if ($Filter) {
                    if ($null -eq ($obj | Where-Object $Filter)) { continue }
                }

                if ($funnelDef) {
                    $matchesAppliesTo = -not $funnelDef.ContainsKey('AppliesTo') -or ($null -ne ($obj | Where-Object $funnelDef.AppliesTo))
                    if ($matchesAppliesTo) {
                        $funnelItems = @($obj | ForEach-Object $funnelDef.Transform) | Where-Object { $_ -ne $null }
                        foreach ($subItem in $funnelItems) {
                            if ($firstReached) { break fileLoop }
                            $relativePath = $file.FullName.Substring($bucketPath.Length).TrimStart([System.IO.Path]::DirectorySeparatorChar)
                            $keyWithoutExt = [System.IO.Path]::ChangeExtension($relativePath, $null).TrimEnd('.')
                            Add-HiddenProperty -Target $subItem -Name '_BucketName' -Value $bucketName
                            Add-HiddenProperty -Target $subItem -Name '_BucketKey' -Value $keyWithoutExt
                            Add-HiddenProperty -Target $subItem -Name '_BucketFile' -Value $file.FullName
                            $null = $allObjects.Add($subItem)
                            if ($firstLimit -gt 0 -and $allObjects.Count -ge $firstLimit) { $firstReached = $true; break fileLoop }
                        }
                        continue
                    }
                }

                $relativePath = $file.FullName.Substring($bucketPath.Length).TrimStart([System.IO.Path]::DirectorySeparatorChar)
                $keyWithoutExt = [System.IO.Path]::ChangeExtension($relativePath, $null).TrimEnd('.')
                Add-HiddenProperty -Target $obj -Name '_BucketName' -Value $bucketName
                Add-HiddenProperty -Target $obj -Name '_BucketKey' -Value $keyWithoutExt
                Add-HiddenProperty -Target $obj -Name '_BucketFile' -Value $file.FullName
                $null = $allObjects.Add($obj)
                if ($firstLimit -gt 0 -and $allObjects.Count -ge $firstLimit) { $firstReached = $true; break fileLoop }
            }

            if ($firstReached) { break }
            if ($Expand -and $Key -and $Key.Count -gt 0) {
                $subDirs = [System.IO.DirectoryInfo]::new($bucketPath).GetDirectories() | Where-Object { $_.Name -ne '.buckets' }
                $keysLower = @($Key | ForEach-Object { $_.ToLowerInvariant() })
                foreach ($subDir in $subDirs) {
                    $dirName = $subDir.Name.ToLowerInvariant()
                    $matched = $false
                    foreach ($tk in $keysLower) {
                        $matched = if ($tk -match '[\*\?]') { $dirName -like $tk } else { $dirName -eq $tk }
                        if ($matched) { break }
                    }
                    if (-not $matched) { continue }
                    $reconstructed = Reconstruct-Object -DirPath $subDir.FullName
                    if ($null -eq $reconstructed) { continue }
                    if ($Filter) {
                        if ($null -eq ($reconstructed | Where-Object $Filter)) { continue }
                    }
                    Add-HiddenProperty -Target $reconstructed -Name '_BucketName' -Value $bucketName
                    Add-HiddenProperty -Target $reconstructed -Name '_BucketKey' -Value $subDir.Name
                    Add-HiddenProperty -Target $reconstructed -Name '_BucketFile' -Value $null
                    $null = $allObjects.Add($reconstructed)
                    if ($firstLimit -gt 0 -and $allObjects.Count -ge $firstLimit) { $firstReached = $true; break }
                }
            }
        }
    }

    $emitted = 0; $skipped = 0
    foreach ($obj in $allObjects) {
        if ($Skip -gt 0 -and $skipped -lt $Skip) { $skipped++; continue }
        if ($First -gt 0 -and $emitted -ge $First) { break }
        Write-Output $obj; $emitted++
    }
}