PSLocalBase.Cmdlets.ps1

<#
.SYNOPSIS
    PSLocalBase.Cmdlets.ps1 - Native PowerShell Cmdlet & Pipeline Adapter for PSLocalBase.
 
.DESCRIPTION
    Provides an idiomatic PowerShell cmdlet interface (Verb-Noun, pipeline integration,
    -WhatIf, -Confirm, tab completion) built as a PowerShell adapter on top
    of the PSLocalBase fluent engine.
 
    Preserves 100% interoperability with the primary fluent API ($db.collection().get()).
#>


# ---------------------------------------------------------------------------
# Module Execution Context & Session Management
# ---------------------------------------------------------------------------
$script:CurrentLocalBaseConnection = $null
# Identity is separate from payload properties, including colliding metadata names.
$script:LocalBaseIdentity = [System.Runtime.CompilerServices.ConditionalWeakTable[object, object]]::new()

function Register-LocalBaseDocumentIdentity {
    param($Document, [string]$Collection, [string]$Id, [bool]$Wrapped = $false)
    $original = @{}
    foreach ($name in @('_localbase_collection', '_localbase_key')) {
        if ($Document.PSObject.Properties[$name]) { $original[$name] = $Document.$name }
    }
    $syntheticId = -not $Document.PSObject.Properties['id']
    $script:LocalBaseIdentity.Add($Document, @{ Collection = $Collection; Id = $Id; Original = $original; SyntheticId = $syntheticId; Wrapped = $Wrapped })
    Add-Member -InputObject $Document -NotePropertyName '_localbase_collection' -NotePropertyValue $Collection -Force
    Add-Member -InputObject $Document -NotePropertyName '_localbase_key' -NotePropertyValue $Id -Force
    if ($syntheticId) { Add-Member -InputObject $Document -NotePropertyName 'id' -NotePropertyValue $Id }
}

function Resolve-LocalBaseDocumentIdentity {
    param($Document, [string]$Collection, [string]$Id)
    $identity = $null
    if ($null -ne $Document) { $null = $script:LocalBaseIdentity.TryGetValue($Document, [ref]$identity) }
    if (-not $Collection) {
        if ($identity) { $Collection = $identity.Collection }
        elseif ($Document.Collection) { $Collection = $Document.Collection }
        elseif ($Document._localbase_collection) { $Collection = $Document._localbase_collection }
    }
    if (-not $Id) {
        if ($identity) { $Id = $identity.Id }
        else {
            foreach ($name in @('id', '_key', 'key', '_localbase_key')) {
                if ($Document.$name) { $Id = $Document.$name; break }
            }
        }
    }
    return @{ Collection = $Collection; Id = $Id }
}

function ConvertTo-LocalBasePayload {
    param($Document)
    $identity = $null
    if ($null -ne $Document -and $script:LocalBaseIdentity.TryGetValue($Document, [ref]$identity)) {
        if ($identity.Wrapped) { return ,$Document.Value }
        $clean = $Document.PSObject.Copy()
        foreach ($name in @('_localbase_collection', '_localbase_key')) {
            $clean.PSObject.Properties.Remove($name)
            if ($identity.Original.ContainsKey($name)) {
                Add-Member -InputObject $clean -NotePropertyName $name -NotePropertyValue $identity.Original[$name]
            }
        }
        if ($identity.SyntheticId) { $clean.PSObject.Properties.Remove('id') }
        return ,$clean
    }
    return ,$Document
}

function Get-LocalBaseExecutionContext {
    [CmdletBinding()]
    param(
        [Parameter()]
        $Database
    )

    if ($Database) {
        return $Database
    }

    if ($script:CurrentLocalBaseConnection -and $script:CurrentLocalBaseConnection.DbHandle -ne [System.IntPtr]::Zero) {
        return $script:CurrentLocalBaseConnection
    }

    throw [System.InvalidOperationException]::new(
        "No active PSLocalBase database connection found. Use 'Connect-LocalBase -Path <path>' to connect, or pass -Database explicitly."
    )
}

# ---------------------------------------------------------------------------
# 1. Connection & Database Management Cmdlets
# ---------------------------------------------------------------------------

function Connect-LocalBase {
    <#
    .SYNOPSIS
        Connects to a PSLocalBase SQLite database and sets it as the active session connection.
    .DESCRIPTION
        Opens an existing SQLite database file or automatically creates and provisions
        a new database file on demand if it does not yet exist. Sets the connection
        as the ambient session default for all downstream pipeline cmdlets.
    .EXAMPLE
        Connect-LocalBase -Path "./data.db"
    .EXAMPLE
        Connect-LocalBase -Path ":memory:"
    #>

    [CmdletBinding()]
    [Alias('Open-LocalBase')]
    param(
        [Parameter(Mandatory = $true, Position = 0)]
        [string]$Path,

        [Parameter()]
        [ValidateRange(0, 2147483647)]
        [int]$BusyTimeoutMs = 5000,

        [Parameter()]
        [ValidateSet('WAL', 'DELETE')]
        [string]$JournalMode = 'WAL',

        [Parameter()]
        [ValidateSet('NORMAL', 'FULL', 'OFF', 'EXTRA')]
        [string]$Synchronous = 'NORMAL',

        [Parameter()]
        [switch]$PassThru
    )

    $opts = @{
        busyTimeout = $BusyTimeoutMs
        wal         = ($JournalMode -eq 'WAL')
        synchronous = $Synchronous
    }

    # Opening/validating may throw. Keep the original ambient connection and transaction intact.
    $db = New-LocalBase -Path $Path -Options $opts
    try {
        if ($script:CurrentLocalBaseConnection) { $script:CurrentLocalBaseConnection.close() }
    } catch {
        $db.close()
        throw
    }
    $script:CurrentLocalBaseConnection = $db

    if ($PassThru) {
        return $db
    }
}

function Disconnect-LocalBase {
    <#
    .SYNOPSIS
        Closes the active PSLocalBase connection and releases all file locks.
    .EXAMPLE
        Disconnect-LocalBase
    #>

    [CmdletBinding()]
    [Alias('Close-LocalBase')]
    param(
        [Parameter()]
        $Database
    )

    $targetDb = if ($Database) { $Database } else { $script:CurrentLocalBaseConnection }
    if ($targetDb) {
        $targetDb.close()
        if ($targetDb -eq $script:CurrentLocalBaseConnection) {
            $script:CurrentLocalBaseConnection = $null
        }
    }
}

function Get-LocalBaseConnection {
    <#
    .SYNOPSIS
        Gets the active PSLocalBase connection for the current session.
    #>

    [CmdletBinding()]
    param()

    return $script:CurrentLocalBaseConnection
}

function Backup-LocalBaseDatabase {
    <#
    .SYNOPSIS
        Performs a live, consistent online backup using SQLite VACUUM INTO.
    .EXAMPLE
        Backup-LocalBaseDatabase -DestinationPath "./backup.db"
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true, Position = 0)]
        [string]$DestinationPath,

        [Parameter()]
        $Database
    )

    $ctx = Get-LocalBaseExecutionContext -Database $Database
    $resolvedPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($DestinationPath)
    $cleanPath = $resolvedPath -replace "'", "''"
    $null = $ctx.query("VACUUM INTO '$cleanPath';")
}

function Remove-LocalBaseDatabase {
    <#
    .SYNOPSIS
        Deletes a PSLocalBase database file and its associated journal files (.db-wal, .db-shm).
    .EXAMPLE
        Remove-LocalBaseDatabase -Path "./old.db" -Confirm:$false
    #>

    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
    param(
        [Parameter(Mandatory = $true, Position = 0)]
        [string]$Path,

        [Parameter()]
        [switch]$Force
    )

    $resolved = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path)
    if ($PSCmdlet.ShouldProcess($resolved, "Delete database file and SQLite journals")) {
        $targets = @($resolved, "$resolved-wal", "$resolved-shm", "$resolved-journal")
        $locks = [System.Collections.Generic.List[System.IDisposable]]::new()
        try {
            # Check every file before removing any. Active SQLite handles prevent
            # exclusive access, including connections outside the ambient context.
            foreach ($target in $targets) {
                if (Test-Path -LiteralPath $target -ErrorAction Stop) {
                    $item = Get-Item -LiteralPath $target -Force -ErrorAction Stop
                    if ($item.PSIsContainer) { throw "Database path is a directory: '$target'." }
                    if ($item.IsReadOnly -and -not $Force) { throw "Database file '$target' is read-only. Use -Force to remove it." }
                    $locks.Add([System.IO.File]::Open($target, 'Open', 'Read', 'None'))
                }
            }
        } catch {
            throw [System.IO.IOException]::new("Cannot remove database '$resolved'. Close all connections and check file permissions. $($_.Exception.Message)", $_.Exception)
        } finally {
            foreach ($fileLock in $locks) { $fileLock.Dispose() }
        }
        foreach ($target in $targets) {
            if (Test-Path -LiteralPath $target -ErrorAction Stop) {
                Remove-Item -LiteralPath $target -Force:$Force -ErrorAction Stop
            }
        }
    }
}

# ---------------------------------------------------------------------------
# 2. Collection & Index Lifecycle Cmdlets
# ---------------------------------------------------------------------------

function Get-LocalBaseCollection {
    <#
    .SYNOPSIS
        Lists all collections present in the active database.
    #>

    [CmdletBinding()]
    param(
        [Parameter()]
        $Database
    )

    $ctx = Get-LocalBaseExecutionContext -Database $Database
    return $ctx.getCollections()
}

function New-LocalBaseCollection {
    <#
    .SYNOPSIS
        Explicitly provisions a collection table in the database schema.
    .EXAMPLE
        New-LocalBaseCollection -Name "customers"
    #>

    [CmdletBinding(SupportsShouldProcess = $true)]
    param(
        [Parameter(Mandatory = $true, Position = 0)]
        [string]$Name,

        [Parameter()]
        $Database
    )

    $ctx = Get-LocalBaseExecutionContext -Database $Database
    if ($PSCmdlet.ShouldProcess($Name, "Create collection")) { $null = $ctx.createCollection($Name) }
}

function Remove-LocalBaseCollection {
    <#
    .SYNOPSIS
        Drops a collection table and all associated indexes from the database.
    .EXAMPLE
        Remove-LocalBaseCollection -Collection "temp_data" -Confirm:$false
    #>

    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
    param(
        [Parameter(Mandatory = $true, Position = 0)]
        [string]$Collection,

        [Parameter()]
        $Database
    )

    if ($PSCmdlet.ShouldProcess($Collection, "Drop collection table and associated index metadata")) {
        $ctx = Get-LocalBaseExecutionContext -Database $Database
        $null = $ctx.collection($Collection).drop()
    }
}

function Clear-LocalBaseCollection {
    <#
    .SYNOPSIS
        Truncates/empties all documents in a collection while preserving its schema table and indexes.
    .EXAMPLE
        Clear-LocalBaseCollection -Collection "logs" -Confirm:$false
    #>

    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')]
    param(
        [Parameter(Mandatory = $true, Position = 0)]
        [string]$Collection,

        [Parameter()]
        $Database
    )

    if ($PSCmdlet.ShouldProcess($Collection, "Delete all documents in collection")) {
        $ctx = Get-LocalBaseExecutionContext -Database $Database
        $null = $ctx.collection($Collection).delete()
    }
}

function New-LocalBaseIndex {
    <#
    .SYNOPSIS
        Creates a secondary or unique expression index on a document field.
    .EXAMPLE
        New-LocalBaseIndex -Collection "users" -Field "email" -Unique
    #>

    [CmdletBinding(SupportsShouldProcess = $true)]
    param(
        [Parameter(Mandatory = $true, Position = 0)]
        [string]$Collection,

        [Parameter(Mandatory = $true, Position = 1)]
        [string]$Field,

        [Parameter()]
        [switch]$Unique,

        [Parameter()]
        $Database
    )

    $ctx = Get-LocalBaseExecutionContext -Database $Database
    if ($PSCmdlet.ShouldProcess("$Collection/$Field", "Create index")) { $null = $ctx.collection($Collection).createIndex($Field, [bool]$Unique) }
}

function Remove-LocalBaseIndex {
    <#
    .SYNOPSIS
        Drops a secondary index on a collection field.
    .EXAMPLE
        Remove-LocalBaseIndex -Collection "users" -Field "email"
    #>

    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')]
    param(
        [Parameter(Mandatory = $true, Position = 0)]
        [string]$Collection,

        [Parameter(Mandatory = $true, Position = 1)]
        [string]$Field,

        [Parameter()]
        $Database
    )

    if ($PSCmdlet.ShouldProcess("$Collection/$Field", "Drop secondary index")) {
        $ctx = Get-LocalBaseExecutionContext -Database $Database
        $null = $ctx.collection($Collection).dropIndex($Field)
    }
}

# ---------------------------------------------------------------------------
# 3. Document CRUD & Pipeline Cmdlets
# ---------------------------------------------------------------------------

function Get-LocalBaseDocument {
    <#
    .SYNOPSIS
        Materializes selected documents, then emits each document to the pipeline.
        Use -First/-Skip to bound reads; downstream Select-Object -First does not reduce database reading.
    .DESCRIPTION
        JSON objects expose their payload properties. Top-level arrays, scalars and null
        expose the payload in Value. Set/Add unwrap these trusted envelopes; Remove uses
        their private identity. Missing point reads emit no item. Distinct rejects paging
        and ordering modifiers. Unsupported numeric comparisons throw instead of rounding.
    .EXAMPLE
        Get-LocalBaseDocument -Collection "users" -Id "usr_100"
    .EXAMPLE
        Get-LocalBaseDocument -Collection "users" -Filter @{ role = 'Admin' } -OrderBy 'age' -Descending -First 10
    .EXAMPLE
        Get-LocalBaseDocument -Collection "items" -Field "price" -Operator ">=" -Value 50
    #>

    [CmdletBinding(DefaultParameterSetName = 'Query')]
    [Alias('gld')]
    param(
        [Parameter(Mandatory = $true, Position = 0)]
        [string]$Collection,

        [Parameter(Mandatory = $true, ParameterSetName = 'ById', Position = 1)]
        [string]$Id,

        [Parameter(ParameterSetName = 'Query')]
        [hashtable]$Filter,

        [Parameter(ParameterSetName = 'Query')]
        [string]$Field,

        [Parameter(ParameterSetName = 'Query')]
        [string]$Operator = '==',

        [Parameter(ParameterSetName = 'Query')]
        [object]$Value,

        [Parameter(ParameterSetName = 'Query')]
        [string]$Exists,

        [Parameter(ParameterSetName = 'Query')]
        [string]$Missing,

        [Parameter(ParameterSetName = 'Query')]
        [string]$OrderBy,

        [Parameter(ParameterSetName = 'Query')]
        [switch]$Descending,

        [Parameter(ParameterSetName = 'Query')]
        [int]$First,

        [Parameter(ParameterSetName = 'Query')]
        [int]$Skip,

        [Parameter()]
        [string[]]$Select,

        [Parameter(ParameterSetName = 'Query')]
        [string]$Distinct,

        [Parameter(ParameterSetName = 'Query')]
        [switch]$Count,

        [Parameter()]
        $Database
    )

    $ctx = Get-LocalBaseExecutionContext -Database $Database
    $col = $ctx.collection($Collection)

    # Point Lookup by Id
    if ($PSCmdlet.ParameterSetName -eq 'ById') {
        $q = $col.doc($Id)
        if ($Select) {
            $q = $q.select($Select)
        }
        $entry = $q.get(@{ keys = $true })
        if ($null -ne $entry) {
            $doc = $entry.data
            $wrapped = $null -eq $doc -or $doc -isnot [System.Management.Automation.PSCustomObject]
            if ($wrapped) { $doc = [pscustomobject]@{ Value = $doc } }
            Register-LocalBaseDocumentIdentity -Document $doc -Collection $Collection -Id $Id -Wrapped $wrapped
            $PSCmdlet.WriteObject($doc, $false)
        }
        return
    }

    $q = $col

    # Apply Filter hashtable
    if ($Filter) {
        foreach ($k in $Filter.Keys) {
            $q = $q.where($k, '==', $Filter[$k])
        }
    }

    # Apply explicit field filter
    if ($Field) {
        $q = $q.where($Field, $Operator, $Value)
    }

    # Apply Exists / Missing
    if ($Exists) {
        $q = $q.whereExists($Exists)
    }
    if ($Missing) {
        $q = $q.whereMissing($Missing)
    }

    # Apply OrderBy
    if ($OrderBy) {
        $dir = if ($Descending) { 'desc' } else { 'asc' }
        $q = $q.orderBy($OrderBy, $dir)
    }

    # Apply Skip & First
    if ($PSBoundParameters.ContainsKey('Skip')) {
        $q = $q.skip($Skip)
    }
    if ($PSBoundParameters.ContainsKey('First')) {
        $q = $q.limit($First)
    }

    # Return Count directly if requested
    if ($Count) {
        return $q.count()
    }

    # Return Distinct values if requested
    if ($Distinct) {
        return $q.distinct($Distinct)
    }

    # Apply Select projection
    if ($Select) {
        $q = $q.select($Select)
    }

    $results = $q.get(@{ keys = $true })
    if ($null -eq $results) { return }

    # Emit the already materialized results through the pipeline
    foreach ($entry in $results) {
        if ($null -ne $entry) {
            $item = $entry.data
            $wrapped = $null -eq $item -or $item -isnot [System.Management.Automation.PSCustomObject]
            if ($wrapped) { $item = [pscustomobject]@{ Value = $item } }

            Register-LocalBaseDocumentIdentity -Document $item -Collection $Collection -Id $entry.key -Wrapped $wrapped
            $PSCmdlet.WriteObject($item, $false)
        }
    }
}

function Add-LocalBaseDocument {
    <#
    .SYNOPSIS
        Inserts one or more documents into a collection. Supports pipeline streaming.
    .EXAMPLE
        Add-LocalBaseDocument -Collection "users" -Data @{ name = 'Carlos'; role = 'Admin' }
    .EXAMPLE
        Get-Process | Add-LocalBaseDocument -Collection "processes"
    #>

    [CmdletBinding()]
    [Alias('ald')]
    param(
        [Parameter(Mandatory = $true, Position = 0, ValueFromPipelineByPropertyName = $true)]
        [string]$Collection,

        [Parameter(Mandatory = $true, ValueFromPipeline = $true, Position = 1)]
        [AllowNull()]
        [AllowEmptyCollection()]
        [object]$Data,

        [Parameter(Position = 2)]
        [string]$Id,

        [Parameter()]
        $Database
    )

    process {
        $cleanData = ConvertTo-LocalBasePayload $Data

        $ctx = Get-LocalBaseExecutionContext -Database $Database
        $col = $ctx.collection($Collection)

        if ($Id) {
            return $col.add($cleanData, $Id)
        } else {
            return $col.add($cleanData)
        }
    }
}

function Set-LocalBaseDocument {
    <#
    .SYNOPSIS
        Sets/replaces a document with a specific key, or upserts if it does not exist.
    .EXAMPLE
        Set-LocalBaseDocument -Collection "users" -Id "usr_1" -Data @{ name = 'Carlos'; active = $true }
    .EXAMPLE
        $user.status = 'Inactive'
        $user | Set-LocalBaseDocument
    #>

    [CmdletBinding(SupportsShouldProcess = $true)]
    [Alias('sld')]
    param(
        [Parameter(Position = 0)]
        [string]$Collection,

        [Parameter(Position = 1)]
        [string]$Id,

        [Parameter(Mandatory = $true, ValueFromPipeline = $true, Position = 2)]
        [AllowNull()]
        [AllowEmptyCollection()]
        [object]$Data,

        [Parameter()]
        $Database
    )

    process {
        $identity = Resolve-LocalBaseDocumentIdentity -Document $Data -Collection $Collection -Id $Id
        $targetCollection = $identity.Collection
        $targetId = $identity.Id

        if (-not $targetCollection) {
            throw [System.ArgumentException]::new("Collection name must be specified via -Collection or pipeline object.")
        }
        if (-not $targetId) {
            throw [System.ArgumentException]::new("Document Id must be specified via -Id or pipeline object.")
        }

        # Strip internal decoration metadata if piped from Get-LocalBaseDocument
        $cleanData = ConvertTo-LocalBasePayload $Data

        $ctx = Get-LocalBaseExecutionContext -Database $Database
        $col = $ctx.collection($targetCollection)
        if ($PSCmdlet.ShouldProcess("$targetCollection/$targetId", "Set document")) { $null = $col.doc($targetId).set($cleanData) }
    }
}

function Update-LocalBaseDocument {
    <#
    .SYNOPSIS
        Updates documents with partial recursive JSON merge. Supports pipeline forwarding.
    .EXAMPLE
        Update-LocalBaseDocument -Collection "users" -Id "usr_1" -Data @{ active = $true }
    .EXAMPLE
        Get-LocalBaseDocument -Collection "users" -Filter @{ role = 'Guest' } |
            Update-LocalBaseDocument -Data @{ expired = $true }
    #>

    [CmdletBinding(SupportsShouldProcess = $true)]
    [Alias('uld')]
    param(
        [Parameter(Position = 0)]
        [string]$Collection,

        [Parameter(Position = 1)]
        [string]$Id,

        [Parameter(Mandatory = $true, Position = 2)]
        [object]$Data,

        [Parameter(ValueFromPipeline = $true)]
        [object]$InputObject,

        [Parameter()]
        $Database
    )

    process {
        $identity = Resolve-LocalBaseDocumentIdentity -Document $InputObject -Collection $Collection -Id $Id
        $targetCollection = $identity.Collection
        $targetId = $identity.Id

        if (-not $targetCollection) {
            throw [System.ArgumentException]::new("Collection name must be specified via -Collection or pipeline object.")
        }
        if (-not $targetId) {
            throw [System.ArgumentException]::new("Document Id must be specified via -Id or pipeline object.")
        }

        if ($PSCmdlet.ShouldProcess("$targetCollection/$targetId", "Update document fields")) {
            $ctx = Get-LocalBaseExecutionContext -Database $Database
            $null = $ctx.collection($targetCollection).doc($targetId).update((ConvertTo-LocalBasePayload $Data))
        }
    }
}

function Remove-LocalBaseDocument {
    <#
    .SYNOPSIS
        Deletes documents by ID, pipeline, or filter with -WhatIf and -Confirm safety.
    .EXAMPLE
        Remove-LocalBaseDocument -Collection "users" -Id "usr_1" -Confirm:$false
    .EXAMPLE
        Get-LocalBaseDocument -Collection "logs" -Filter @{ level = 'Debug' } |
            Remove-LocalBaseDocument -WhatIf
    #>

    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')]
    [Alias('rld')]
    param(
        [Parameter(Position = 0)]
        [string]$Collection,

        [Parameter(Position = 1)]
        [string]$Id,

        [Parameter(ValueFromPipeline = $true)]
        [object]$InputObject,

        [Parameter()]
        [hashtable]$Filter,

        [Parameter()]
        $Database
    )

    process {
        $identity = Resolve-LocalBaseDocumentIdentity -Document $InputObject -Collection $Collection -Id $Id
        $targetCollection = $identity.Collection
        $targetId = $identity.Id

        $ctx = Get-LocalBaseExecutionContext -Database $Database

        if ($targetId) {
            if (-not $targetCollection) {
                throw [System.ArgumentException]::new("Collection name must be specified via -Collection or pipeline object.")
            }
            $target = $ctx.collection($targetCollection).doc($targetId)
            if ($Filter) {
                foreach ($k in $Filter.Keys) {
                    $target = $target.where($k, '==', $Filter[$k])
                }
            }
            $desc = if ($Filter) { "$targetCollection/$targetId (filtered by $(($Filter.Keys -join ', ')))" } else { "$targetCollection/$targetId" }
            if ($PSCmdlet.ShouldProcess($desc, "Delete document")) {
                $null = $target.delete()
            }
        }
        elseif ($Filter -and $targetCollection) {
            if ($Filter.Count -eq 0) {
                throw [System.ArgumentException]::new("Empty -Filter is not allowed when deleting without -Id. To delete all documents in a collection, use Clear-LocalBaseCollection.")
            }
            $col = $ctx.collection($targetCollection)
            $q = $col
            foreach ($k in $Filter.Keys) {
                $q = $q.where($k, '==', $Filter[$k])
            }
            if ($PSCmdlet.ShouldProcess("$targetCollection (filtered by $(($Filter.Keys -join ', ')))", "Delete matching documents")) {
                $null = $q.delete()
            }
        }
        else {
            throw [System.ArgumentException]::new("Specify either -Id (or pipeline object) or -Filter to delete documents.")
        }
    }
}

function Measure-LocalBaseDocument {
    <#
    .SYNOPSIS
        Calculates mathematical aggregations (Sum, Average, Min, Max, Count) on a numeric field.
    .EXAMPLE
        Measure-LocalBaseDocument -Collection "sales" -Field "amount" -Sum -Average
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true, Position = 0)]
        [string]$Collection,

        [Parameter(Mandatory = $true, Position = 1)]
        [string]$Field,

        [Parameter()]
        [hashtable]$Filter,

        [Parameter()]
        [switch]$Sum,

        [Parameter()]
        [switch]$Average,

        [Parameter()]
        [switch]$Minimum,

        [Parameter()]
        [switch]$Maximum,

        [Parameter()]
        $Database
    )

    $ctx = Get-LocalBaseExecutionContext -Database $Database
    $q = $ctx.collection($Collection)

    if ($Filter) {
        foreach ($k in $Filter.Keys) {
            $q = $q.where($k, '==', $Filter[$k])
        }
    }

    $out = [ordered]@{
        Collection = $Collection
        Field      = $Field
        Count      = $q.count()
    }

    if ($Sum) { $out['Sum'] = $q.sum($Field) }
    if ($Average) { $out['Average'] = $q.avg($Field) }
    if ($Minimum) { $out['Minimum'] = $q.min($Field) }
    if ($Maximum) { $out['Maximum'] = $q.max($Field) }

    return [PSCustomObject]$out
}

# ---------------------------------------------------------------------------
# 4. Transaction Cmdlets
# ---------------------------------------------------------------------------

function Invoke-LocalBaseTransaction {
    <#
    .SYNOPSIS
        Executes a scriptblock inside an atomic SQLite transaction (BEGIN IMMEDIATE / COMMIT / ROLLBACK).
    .EXAMPLE
        Invoke-LocalBaseTransaction {
            Add-LocalBaseDocument -Collection "accounts" -Data @{ id = 1; balance = 90 }
            Add-LocalBaseDocument -Collection "accounts" -Data @{ id = 2; balance = 110 }
        }
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true, Position = 0)]
        [scriptblock]$ScriptBlock,

        [Parameter()]
        $Database
    )

    $ctx = Get-LocalBaseExecutionContext -Database $Database
    # Reject same-connection nesting before changing ambient state.
    $null = $ctx.beginTransaction()
    $previousConnection = $script:CurrentLocalBaseConnection
    $previousPreference = $ErrorActionPreference
    try {
        $script:CurrentLocalBaseConnection = $ctx
        $ErrorActionPreference = 'Stop'
        # Caller-created scriptblocks retain their session state. Inject the
        # preference into the invocation scope instead of only this module scope.
        $variables = [System.Collections.Generic.List[System.Management.Automation.PSVariable]]::new()
        $variables.Add([System.Management.Automation.PSVariable]::new('ErrorActionPreference', 'Stop'))
        $result = $ScriptBlock.InvokeWithContext($null, $variables, @())
        $null = $ctx.commit()
        return $result
    }
    catch {
        try { $null = $ctx.rollback() } catch { Write-Verbose "Rollback failed: $($_.Exception.Message)" }
        throw
    }
    finally {
        $script:CurrentLocalBaseConnection = $previousConnection
        $ErrorActionPreference = $previousPreference
    }
}

function Start-LocalBaseTransaction {
    <#
    .SYNOPSIS
        Manually starts an atomic transaction on the database.
    #>

    [CmdletBinding(SupportsShouldProcess = $true)]
    param([Parameter()]$Database)

    $ctx = Get-LocalBaseExecutionContext -Database $Database
    if ($PSCmdlet.ShouldProcess("Database", "Begin transaction")) { $null = $ctx.beginTransaction() }
}

function Complete-LocalBaseTransaction {
    <#
    .SYNOPSIS
        Commits all pending changes in the current transaction.
    #>

    [CmdletBinding()]
    param([Parameter()]$Database)

    $ctx = Get-LocalBaseExecutionContext -Database $Database
    $null = $ctx.commit()
}

function Undo-LocalBaseTransaction {
    <#
    .SYNOPSIS
        Rolls back all pending changes in the current transaction.
    #>

    [CmdletBinding()]
    param([Parameter()]$Database)

    $ctx = Get-LocalBaseExecutionContext -Database $Database
    $null = $ctx.rollback()
}