OverviewFilesCommitsIssuesDocsReleasesPeers

pgh / lib / commands / store.js

192 lines · javascript ref:
/** pgh store list|size|gc|pin|unpin — manage the tree store */

import { TreeStore } from '../store.js'
import { withRegistry } from './releases.js'

// Default age-gate for `pgh store gc`. 14 days matches the retention
// policy adopted on the retention-policy branch — short enough to keep
// the store small, long enough that a publish that just landed but
// hasn't been linked into an app yet survives. Override per-invocation
// via --max-age <days>.
const DEFAULT_GC_MAX_AGE_DAYS = 14

export const usage = `pgh store — manage the content-addressed tree store

  pgh store list                           Enumerate every tree in the store
  pgh store size                           Total bytes + tree counts
  pgh store gc [--dry-run] [--max-age <d>] Remove unreferenced, unpinned trees;
                                           --max-age <d> grace period in days
                                           (default: ${DEFAULT_GC_MAX_AGE_DAYS}). 0 = no grace.
      [--keep-releases <n>]                Also keep each app's newest n releases
                                           and drop trees only older releases use,
                                           whatever their age.
  pgh store pin <hash>                      Mark tree as ungcable
  pgh store unpin <hash>                    Remove pin
  pgh store show <hash>                     Show details for one tree
  pgh store verify                          Audit every blob; report any whose
                                           bytes no longer match their content-hash
  pgh store repair [--dry-run]              Quarantine corrupt blobs + invalidate
                                           the trees that hardlink them`

function fmtBytes (n) {
  if (n < 1024) return n + ' B'
  if (n < 1048576) return (n / 1024).toFixed(1) + ' KB'
  if (n < 1073741824) return (n / 1048576).toFixed(1) + ' MB'
  return (n / 1073741824).toFixed(2) + ' GB'
}

function flagValue (args, name) {
  const idx = args.indexOf(name)
  if (idx === -1) return undefined
  return args[idx + 1]
}

export async function run (client, args) {
  const store = new TreeStore()
  const sub = args[0]

  if (!sub || sub === 'help') {
    return usage
  }

  if (sub === 'size' || sub === 'stats') {
    const s = store.stats()
    return {
      treeCount: s.treeCount,
      referencedCount: s.referencedCount,
      unreferencedCount: s.unreferencedCount,
      pinnedCount: s.pinnedCount,
      totalBytes: s.totalBytes,
      totalSize: fmtBytes(s.totalBytes)
    }
  }

  if (sub === 'list' || sub === 'ls') {
    return store.listTrees().map(t => ({
      hash: t.hash.slice(0, 12),
      size: fmtBytes(t.size),
      refs: t.refs.length,
      refList: t.refs.join(','),
      pinned: t.pinned
    }))
  }

  if (sub === 'show') {
    const hash = args[1]
    if (!hash) throw new Error('usage: pgh store show <hash>')
    const match = store.listTrees().find(t => t.hash === hash || t.hash.startsWith(hash))
    if (!match) throw new Error('no tree matching hash prefix: ' + hash)
    return {
      hash: match.hash,
      size: fmtBytes(match.size),
      sizeBytes: match.size,
      refs: match.refs,
      pinned: match.pinned,
      meta: match.meta,
      path: store.treePath(match.hash)
    }
  }

  if (sub === 'gc') {
    const dryRun = args.includes('--dry-run')
    const ageRaw = flagValue(args, '--max-age')
    let maxAgeDays = DEFAULT_GC_MAX_AGE_DAYS
    if (ageRaw !== undefined) {
      const parsed = parseInt(ageRaw, 10)
      if (!Number.isFinite(parsed) || parsed < 0) {
        throw new Error('--max-age requires a non-negative integer (days)')
      }
      maxAgeDays = parsed
    }
    const keepRaw = flagValue(args, '--keep-releases')
    let keepReleases
    let releases
    if (keepRaw !== undefined) {
      keepReleases = parseInt(keepRaw, 10)
      if (!Number.isFinite(keepReleases) || keepReleases < 1) {
        throw new Error('--keep-releases requires a positive integer')
      }
      // With the sidecar up, IT runs the gc: it holds the registry, and the
      // full release list (every entry's dep closure) overflows the UDS frame.
      const remote = await withRegistry(async (reg) => {
        if (typeof reg.storeGc === 'function') return reg.storeGc({ dryRun, keepReleases })
        releases = await reg.list()
        return null
      })
      if (remote) {
        return {
          candidates: remote.candidates.length,
          removed: remote.removed.length,
          kept: remote.kept,
          blobsRemoved: remote.blobsRemoved,
          maxAgeDays: 14,
          keepReleases,
          dryRun,
          by: 'pgh-sidecar',
          removedHashes: dryRun ? remote.candidates : remote.removed
        }
      }
    }
    const result = store.gc({ dryRun, maxAgeDays, keepReleases, releases })
    return {
      candidates: result.candidates.length,
      removed: result.removed.length,
      kept: result.kept.length,
      maxAgeDays,
      keepReleases: keepReleases || null,
      dryRun,
      removedHashes: dryRun ? result.candidates : result.removed
    }
  }

  if (sub === 'pin') {
    const hash = args[1]
    if (!hash) throw new Error('usage: pgh store pin <hash>')
    const match = store.listTrees().find(t => t.hash === hash || t.hash.startsWith(hash))
    if (!match) throw new Error('no tree matching hash prefix: ' + hash)
    store.pin(match.hash)
    return { pinned: match.hash }
  }

  if (sub === 'unpin') {
    const hash = args[1]
    if (!hash) throw new Error('usage: pgh store unpin <hash>')
    const match = store.listTrees().find(t => t.hash === hash || t.hash.startsWith(hash))
    if (!match) throw new Error('no tree matching hash prefix: ' + hash)
    store.unpin(match.hash)
    return { unpinned: match.hash }
  }

  if (sub === 'verify') {
    const { checked, corrupt } = store.verifyBlobs()
    return {
      checked,
      corrupt: corrupt.length,
      ok: corrupt.length === 0,
      corruptBlobs: corrupt.map(c => ({
        expected: c.expected.slice(0, 16) + '…',
        actual: c.actual.slice(0, 16) + '…',
        path: c.path
      }))
    }
  }

  if (sub === 'repair') {
    const dryRun = args.includes('--dry-run')
    const result = store.repair({ dryRun })
    return {
      checked: result.checked,
      corruptBlobs: result.corruptBlobs.length,
      removedBlobs: result.removedBlobs.length,
      invalidatedTrees: result.invalidatedTrees.length,
      invalidatedHashes: result.invalidatedTrees,
      dryRun: result.dryRun,
      note: result.invalidatedTrees.length
        ? 'Invalidated trees must be re-fetched from a clean peer or re-published (their correct bytes are gone from this machine).'
        : 'Store is consistent — nothing to repair.'
    }
  }

  throw new Error('unknown store subcommand: ' + sub)
}