OverviewFilesCommitsIssuesDocsReleasesPeers

pgh / lib / store.js

840 lines · javascript ref:
/**
 * TreeStore — device-wide content-addressable tree store.
 *
 * Layout:
 *   ~/.local/share/store/
 *     apps/<name>/             ← per-install shell (built by a materializer)
 *     dependencies/<hash>/     ← content-addressed tree — shared across apps
 *     meta/<hash>.json         ← { pulledAt, source?, pinned }
 *     refs.json                ← { <hash>: [<appName>, ...] }
 *     tmp/<partial>/           ← atomic-rename landing zone
 *
 * Any runtime's materializer (Node, Godot, future others) reads from the
 * same `dependencies/` store — that's what gives cross-runtime dedup. The
 * store itself is runtime-agnostic: it holds bytes keyed by content hash.
 *
 * Materialization is atomic per tree: files land in `tmp/`, rename on
 * completion. Cross-app dedup works because identical content produces
 * identical tree hashes, so two apps needing the same dep version both
 * reference the same materialized tree.
 *
 * Scope boundary: v1 is top-level-tree dedup only — each tree is
 * self-contained, no cross-tree symlinking internally. Blob-level
 * dedup via hardlinks is a later optimization (pnpm-style).
 */

import process from '#node:process'
import fs from '#node:fs'
import path from '#node:path'
import { homedir } from '#node:os'

import crypto from 'hypercore-crypto'
import { KIND_FILE, KIND_DIR, KIND_SYMLINK, computeTreeHash, walkTree } from 'hyper-overlay/tree'

// Default store root — env override honored so tests + parallel
// installs on the same machine can run with isolated stores.
export const DEFAULT_ROOT = process.env.PGH_STORE_ROOT ||
  path.join(homedir(), '.local', 'share', 'store')

// Bounded concurrency for content/subtree fetches during materialize. The
// original walk fetched every file strictly serially — a cold install of a
// many-file closure (e.g. hyper-tps ~11k files) paid per-file network RTT
// end to end (measured ~1.8 MB/s even over loopback). Overlapping up to N
// fetches removes that serialization. Safe because content-addressed writes
// are already race-tolerant (atomic blob rename + hardlink EEXIST guard), so
// two entries resolving to the same blob concurrently is a no-op collision.
// Tune via PGH_MATERIALIZE_CONCURRENCY (default 16).
export const MATERIALIZE_CONCURRENCY = Math.max(1,
  parseInt(process.env.PGH_MATERIALIZE_CONCURRENCY, 10) || 16)

class Semaphore {
  constructor (max) { this._max = max; this._active = 0; this._queue = [] }
  acquire () {
    if (this._active < this._max) { this._active++; return Promise.resolve() }
    return new Promise((resolve) => this._queue.push(resolve))
  }
  release () {
    const next = this._queue.shift()
    if (next) next() // transfer the slot to a waiter (active count unchanged)
    else this._active--
  }
  async run (fn) {
    await this.acquire()
    try { return await fn() } finally { this.release() }
  }
}

export class TreeStore {
  constructor (opts = {}) {
    this.root = opts.root || DEFAULT_ROOT
    this.dependenciesDir = path.join(this.root, 'dependencies')
    this.appsDir = path.join(this.root, 'apps')
    this.metaDir = path.join(this.root, 'meta')
    this.tmpDir = path.join(this.root, 'tmp')
    this.blobsDir = path.join(this.root, 'blobs')
    this.refsPath = path.join(this.root, 'refs.json')
    this._initialized = false
  }

  init () {
    if (this._initialized) return
    for (const d of [this.root, this.dependenciesDir, this.appsDir, this.metaDir, this.tmpDir, this.blobsDir]) {
      fs.mkdirSync(d, { recursive: true })
    }
    if (!fs.existsSync(this.refsPath)) {
      fs.writeFileSync(this.refsPath, '{}')
    }
    this._initialized = true
  }

  // ── Queries ────────────────────────────────────────────────

  treePath (hash) { return path.join(this.dependenciesDir, hash) }
  appPath (name) { return path.join(this.appsDir, name) }
  metaPath (hash) { return path.join(this.metaDir, hash + '.json') }
  // Blob layer: one inode per unique content. Tree dirs hardlink into here.
  // Files are sharded by the first two hex chars to keep directory entry
  // counts reasonable as the store scales (mirrors git's object layout).
  blobPath (contentHash, exec) {
    const tail = exec ? contentHash.slice(2) + '.x' : contentHash.slice(2)
    return path.join(this.blobsDir, contentHash.slice(0, 2), tail)
  }

  // BLOB_LAYER_SCHEMA: bumps when the on-disk shape changes in a way
  // that breaks walkTree round-trip. Old trees without this in meta are
  // treated as not-present and re-materialized on demand. v2 = exec-bit
  // sharded blobs (modes preserved); v1 = legacy all-0755.
  static BLOB_LAYER_SCHEMA = 2

  hasTree (hash) {
    if (!fs.existsSync(this.treePath(hash))) return false
    const meta = this.getMeta(hash)
    if (!meta || (meta.schema || 1) < TreeStore.BLOB_LAYER_SCHEMA) return false
    return true
  }

  // Meta is a CACHE — pulledAt, source, pinned, schema. It is derived bookkeeping, never the tree
  // itself, so an unreadable one must degrade to "no meta" rather than take the caller down.
  //
  // THE BUG THIS FIXES. `existsSync` guarded a MISSING file but not an EMPTY or truncated one, and
  // JSON.parse('') throws "Unexpected end of JSON input". A single zero-byte meta file on stortron
  // therefore broke `pgh store show` and `pgh store pin` for EVERY hash — the reader throws before it
  // can tell you which file is at fault. That is why hyper-social #24 recorded "stortron has no
  // pinned hpp rollback tree" as an open item: rollback pins could not be taken on that host at all,
  // which is the worst possible moment to lose a safety net.
  //
  // `_readRefs` in this same class already reads-and-tolerates exactly this way; getMeta was the
  // outlier. And `pin()` below already handles a null meta (`|| { pulledAt: Date.now() }`), so
  // returning null here lets a corrupt entry HEAL on the next pin rather than needing a manual fix.
  getMeta (hash) {
    const p = this.metaPath(hash)
    if (!fs.existsSync(p)) return null
    try {
      const raw = fs.readFileSync(p, 'utf8')
      if (!raw.trim()) return null
      const parsed = JSON.parse(raw)
      return (parsed && typeof parsed === 'object') ? parsed : null
    } catch {
      return null
    }
  }

  // ── Materialization ─────────────────────────────────────────

  /**
   * Materialize a tree into the store. Idempotent — returns early if
   * the tree already exists locally.
   *
   * @param {string} hash - tree hash (hex)
   * @param {Array} entries - tree entries as returned by tree-response / fileIndex.getTree
   * @param {object} opts
   * @param {Function} opts.fetchContent - async (contentHash) => Buffer
   *   Called for each file-kind entry to obtain its bytes. Must return a Buffer
   *   whose blake2b-256 equals contentHash.
   * @param {Function} opts.fetchTree - async (treeHash) => Array<entries>
   *   Called for each dir-kind entry whose subtree isn't yet local. Must return
   *   the entries list for that subtree.
   * @param {Function} [opts.skipPath] - (relPath) => boolean. Called for every
   *   dir-kind entry with the path-from-tree-root (POSIX, no leading slash).
   *   Returning true skips fetching that subtree and creating its directory.
   *   Used by host-aware installs to avoid pulling foreign by-arch subtrees.
   *   Note: a tree materialized with skipPath is host-specific on disk —
   *   peers asking for the skipped subtree must fetch from the publisher.
   * @param {object} [opts.meta] - { source?, pinned? } to record in meta/
   */
  async materialize (hash, entries, opts) {
    this.init()
    if (this.hasTree(hash)) {
      this._writeMeta(hash, { pulledAt: Date.now(), source: opts && opts.source, pinned: false, ...(this.getMeta(hash) || {}), schema: TreeStore.BLOB_LAYER_SCHEMA })
      return this.treePath(hash)
    }

    // Legacy tree dir present but schema is stale (or missing) — drop it
    // and re-materialize under the current blob layout. Apps that referenced
    // it via symlink stop working until reinstalled; but the tree was
    // already non-round-trippable, so peer-serving was broken anyway.
    if (fs.existsSync(this.treePath(hash))) {
      fs.rmSync(this.treePath(hash), { recursive: true, force: true })
    }

    const tmp = path.join(this.tmpDir, hash + '-' + Date.now())
    fs.mkdirSync(tmp, { recursive: true })

    try {
      const sem = new Semaphore((opts && opts.concurrency) || MATERIALIZE_CONCURRENCY)
      await this._materializeInto(tmp, entries, opts, '', sem)

      // Integrity guard: the just-materialized tree MUST round-trip to its
      // own hash. If it doesn't, a content blob on disk is corrupt — its
      // bytes no longer match its content-hash (a classic in-place overwrite
      // of a hardlinked blob), so serving/installing this tree would fail
      // with "tree not found" on every peer. Reject loudly HERE instead of
      // landing a poisoned tree that silently breaks the whole fleet.
      //
      // Skipped when opts.skipPath is set: a host-aware materialization omits
      // foreign-arch subtrees on purpose, so it legitimately does not
      // round-trip to the full hash. opts.verify === false also opts out.
      if ((!opts || opts.verify !== false) && !(opts && opts.skipPath)) {
        let actual = null
        try { actual = walkTree(tmp).get('').hash } catch {}
        if (actual !== hash) {
          try { fs.rmSync(tmp, { recursive: true, force: true }) } catch {}
          const err = new Error(
            'materialized tree ' + hash.slice(0, 12) + '… does not round-trip ' +
            '(on-disk content hashes to ' + (actual ? actual.slice(0, 12) + '…' : 'null') + '). ' +
            'A content-addressed blob is corrupt — most likely an in-place ' +
            'write over a hardlinked blob. Run `pgh store repair`.')
          err.code = 'EBLOBCORRUPT'
          err.expected = hash
          err.actual = actual
          throw err
        }
      }

      // Atomic rename into place. If another process raced us here, the rename
      // fails on Linux (non-empty target), which we treat as success.
      const final = this.treePath(hash)
      try {
        fs.renameSync(tmp, final)
      } catch (err) {
        if (err.code === 'ENOTEMPTY' || err.code === 'EEXIST') {
          // Someone else won the race — drop our tmp copy.
          fs.rmSync(tmp, { recursive: true, force: true })
        } else {
          throw err
        }
      }

      this._writeMeta(hash, {
        pulledAt: Date.now(),
        source: opts && opts.source,
        pinned: (opts && opts.pinned) || false,
        schema: TreeStore.BLOB_LAYER_SCHEMA
      })

      return final
    } catch (err) {
      try { fs.rmSync(tmp, { recursive: true, force: true }) } catch {}
      throw err
    }
  }

  async _materializeInto (destDir, entries, opts, currentPath, sem) {
    if (!sem) sem = new Semaphore(MATERIALIZE_CONCURRENCY)
    // Entries at each level are processed concurrently; the shared semaphore
    // caps in-flight fetchContent/fetchTree calls across the whole recursion.
    // Sync fs ops (mkdir/link/write) stay ordered within each entry.
    await Promise.all(entries.map(async (e) => {
      const target = path.join(destDir, e.name)
      const entryPath = currentPath ? currentPath + '/' + e.name : e.name

      if (e.kind === KIND_FILE) {
        // Blob layer: one canonical inode per (contentHash, exec-bit) at
        // `blobs/<aa>/<rest>[.x]`; tree-dir entries hardlink into it.
        // Sharding by exec is required because hardlinks share inode
        // mode — if the blob is 0755 but the tree expects 0644, a
        // walkTree round-trip computes a different tree-hash and the
        // tree becomes unreachable by its original hash.
        const blobPath = this.blobPath(e.childHash, e.exec)
        if (!fs.existsSync(blobPath)) {
          const content = await sem.run(() => opts.fetchContent(e.childHash))
          if (!Buffer.isBuffer(content)) {
            throw new Error('fetchContent must return a Buffer for ' + e.childHash)
          }
          verifyContent(e.childHash, content, 'file "' + e.name + '"')
          fs.mkdirSync(path.dirname(blobPath), { recursive: true })
          const tmpBlob = blobPath + '.tmp-' + Date.now() + '-' + process.pid
          fs.writeFileSync(tmpBlob, content)
          // Blobs are content-addressed and IMMUTABLE. Write them READ-ONLY
          // (drop the write bit) so an accidental in-place write to ANY
          // hardlink — e.g. a bootstrap/tar/cp/launcher write over a
          // materialized `bin/<x>` — is rejected by the OS with EACCES
          // instead of silently mutating the shared inode and corrupting
          // every tree that hardlinks it (the 2026-06-04 bin/pgh incident).
          // The exec bit is still preserved via the sharded blob path.
          try { fs.chmodSync(tmpBlob, e.exec ? 0o555 : 0o444) } catch {}
          try {
            fs.renameSync(tmpBlob, blobPath)
          } catch (err) {
            if (err.code === 'ENOTEMPTY' || err.code === 'EEXIST') {
              try { fs.unlinkSync(tmpBlob) } catch {}
            } else {
              try { fs.unlinkSync(tmpBlob) } catch {}
              throw err
            }
          }
        }
        if (typeof fs.linkSync !== 'function') {
          // Old bundled bare-fs (< 4.7.0, e.g. a nested closure copy) has NO
          // linkSync — `fs.linkSync` is undefined and the call would throw a
          // TypeError that propagates mid-materialize and corrupts the partial
          // install. Copy the (already content-addressed + verified) blob
          // instead; loses hardlink dedup but preserves correctness and the
          // read-only immutability the store relies on.
          fs.copyFileSync(blobPath, target)
          fs.chmodSync(target, e.exec ? 0o555 : 0o444)
        } else {
          try {
            fs.linkSync(blobPath, target)
          } catch (err) {
            if (err.code === 'EEXIST') {
              // Target already exists (concurrent materialize finished).
            } else if (err.code === 'EXDEV' || err.code === 'EACCES' || err.code === 'EPERM') {
              // Hardlink impossible: EXDEV (cross-device), or the OS/policy
              // denies link(2) outright — EPERM (sandboxed fs) or EACCES
              // (SELinux `link` denial, e.g. a no-root Android node / celltron,
              // where sepolicy forbids link in every unprivileged domain). The
              // blob is content-addressed + verified, so copy is correctness-
              // preserving; we only lose inode dedup. Matches the documented
              // "degrades to copy on EXDEV/EPERM/etc." contract in preflight.js.
              const content = await sem.run(() => opts.fetchContent(e.childHash))
              verifyContent(e.childHash, content, 'file "' + e.name + '" (link-denied fallback)')
              fs.writeFileSync(target, content)
              // Read-only: see immutable-blob rationale above.
              fs.chmodSync(target, e.exec ? 0o555 : 0o444)
            } else {
              throw err
            }
          }
        }
      } else if (e.kind === KIND_DIR) {
        if (opts.skipPath && opts.skipPath(entryPath)) return
        const subEntries = await sem.run(() => opts.fetchTree(e.childHash))
        if (!Array.isArray(subEntries)) {
          throw new Error('fetchTree must return an entries array for ' + e.childHash)
        }
        // Subtree verification: the returned entries must hash to the
        // advertised childHash. Empty entries for a non-empty subtree
        // (the stortron-class bug) fails here instead of silently
        // materializing an empty dir.
        verifyTreeEntries(e.childHash, subEntries, 'dir "' + e.name + '"')
        fs.mkdirSync(target, { recursive: true })
        await this._materializeInto(target, subEntries, opts, entryPath, sem)
      } else if (e.kind === KIND_SYMLINK) {
        const linkBytes = await sem.run(() => opts.fetchContent(e.childHash))
        verifyContent(e.childHash, linkBytes, 'symlink "' + e.name + '"')
        const linkTarget = linkBytes.toString('utf8')
        try {
          fs.symlinkSync(linkTarget, target)
        } catch (err) {
          if (err.code === 'EPERM') {
            // Windows/permission failure — fall back to writing the target string
            fs.writeFileSync(target + '.symlink', linkTarget)
          } else {
            throw err
          }
        }
      } else {
        throw new Error('unknown entry kind: ' + e.kind)
      }
    }))
  }

  _writeMeta (hash, data) {
    fs.writeFileSync(this.metaPath(hash), JSON.stringify(data, null, 2))
  }

  // ── Refcounts ──────────────────────────────────────────────

  _readRefs () {
    this.init()
    try {
      return JSON.parse(fs.readFileSync(this.refsPath, 'utf8'))
    } catch {
      return {}
    }
  }

  _writeRefs (refs) {
    fs.writeFileSync(this.refsPath, JSON.stringify(refs, null, 2))
  }

  addRef (hash, appName) {
    const refs = this._readRefs()
    if (!refs[hash]) refs[hash] = []
    if (!refs[hash].includes(appName)) refs[hash].push(appName)
    this._writeRefs(refs)
  }

  removeRef (hash, appName) {
    const refs = this._readRefs()
    if (refs[hash]) {
      refs[hash] = refs[hash].filter(a => a !== appName)
      if (refs[hash].length === 0) delete refs[hash]
      this._writeRefs(refs)
    }
  }

  /**
   * Atomically replace the full set of hashes referenced by an app.
   *
   * Old behavior was add-only: every install/publish appended refs but
   * nothing cleared them, so refs.json accumulated stale per-version
   * entries forever and defeated `gc`. setAppRefs is the canonical way
   * to record "appName currently references EXACTLY these hashes" —
   * any hash previously referenced by appName but absent from `hashes`
   * has its appName entry removed.
   *
   * Materializers and link/swap helpers should call this once at the
   * end of a materialize cycle with the complete hash set, instead of
   * a stream of addRef calls.
   *
   * @param {string} appName
   * @param {string[]|Set<string>} hashes - all hashes appName should reference
   */
  setAppRefs (appName, hashes) {
    const want = new Set(hashes)
    const refs = this._readRefs()
    // First pass — drop appName from any hash not in `want`.
    for (const [hash, apps] of Object.entries(refs)) {
      if (!want.has(hash) && apps.includes(appName)) {
        const next = apps.filter(a => a !== appName)
        if (next.length === 0) delete refs[hash]
        else refs[hash] = next
      }
    }
    // Second pass — add appName to every hash in `want`.
    for (const hash of want) {
      if (!refs[hash]) refs[hash] = []
      if (!refs[hash].includes(appName)) refs[hash].push(appName)
    }
    this._writeRefs(refs)
  }

  /**
   * Remove every ref held by appName. Used by uninstall paths that
   * want a clean tear-down before the materializer rewires.
   */
  clearAppRefs (appName) {
    this.setAppRefs(appName, [])
  }

  getRefs (hash) {
    const refs = this._readRefs()
    return refs[hash] || []
  }

  listAllRefs () {
    return this._readRefs()
  }

  // ── Pinning ────────────────────────────────────────────────

  pin (hash) {
    const meta = this.getMeta(hash) || { pulledAt: Date.now() }
    meta.pinned = true
    this._writeMeta(hash, meta)
  }

  unpin (hash) {
    const meta = this.getMeta(hash)
    if (meta) {
      meta.pinned = false
      this._writeMeta(hash, meta)
    }
  }

  isPinned (hash) {
    const meta = this.getMeta(hash)
    return meta ? !!meta.pinned : false
  }

  /**
   * Find app install dirs under `apps/` that no longer appear in refs.json
   * (no dependency tree is recorded against them). These are stale installs
   * that the dependency/blob GC can never reclaim: `gc()` only sweeps
   * `dependencies/` + orphan blobs, so an app that fell out of refs.json
   * (e.g. a removed `*-test` install) keeps its files forever as sole
   * nlink-1 copies. Detection only — removal is opt-in via
   * `gc({ sweepOrphanApps: true })`.
   *
   * @returns {string[]} app names present on disk but absent from refs.json
   */
  listOrphanApps () {
    this.init()
    const refs = this._readRefs()
    const referenced = new Set()
    for (const apps of Object.values(refs)) {
      for (const a of apps) referenced.add(a)
    }
    const orphans = []
    let names
    try { names = fs.readdirSync(this.appsDir) } catch { return orphans }
    for (const name of names) {
      let st
      try { st = fs.statSync(this.appPath(name)) } catch { continue }
      if (!st.isDirectory()) continue
      if (!referenced.has(name)) orphans.push(name)
    }
    return orphans
  }

  // ── GC ─────────────────────────────────────────────────────

  /**
   * Sweep unreferenced trees. Respects pinned flag and optional age gate.
   *
   * Default policy is "ephemeral" — any tree that is unreferenced and
   * unpinned is a candidate regardless of age. Setting `maxAgeDays`
   * gives a grace period: a tree must have been pulled more than
   * maxAgeDays days ago before it's eligible. This protects publishes
   * that have just landed but haven't been linked into an app yet.
   *
   * @param {object} [opts]
   * @param {boolean} [opts.dryRun] - report only, don't delete
   * @param {number} [opts.maxAgeDays] - grace period; only sweep trees
   *   whose meta.pulledAt is older than this many days. Omitted/null =
   *   no grace (legacy behavior, equivalent to maxAgeDays=0).
   * @param {boolean} [opts.sweepOrphanApps] - also remove `apps/<name>` dirs
   *   that are absent from refs.json (default false — orphan apps are always
   *   REPORTED in the return value, but only removed when this is set and
   *   dryRun is off). Removing them frees their now-redundant blob hardlinks
   *   in the same pass.
   * @param {number} [opts.keepReleases] - with `opts.releases`: keep every tree
   *   of each app's newest N releases, and sweep the trees of its older
   *   releases REGARDLESS of age. The age gate alone never collects an app
   *   that publishes several times a day (2026-10-11: 75 copies of a ~600 MB
   *   hypertps.pck, all younger than 14 days). Trees in no release keep the
   *   age gate — it is what protects a publish not yet in the registry.
   * @param {object[]} [opts.releases] - release entries (ReleaseRegistry.list())
   * @returns {{ candidates, removed, kept, blobsRemoved, orphanApps, appsRemoved }}
   */
  gc (opts = {}) {
    this.init()
    const refs = this._readRefs()
    const allHashes = fs.readdirSync(this.dependenciesDir)
    const candidates = []
    const removed = []
    const kept = []

    const cutoff = (typeof opts.maxAgeDays === 'number' && opts.maxAgeDays > 0)
      ? Date.now() - (opts.maxAgeDays * 24 * 60 * 60 * 1000)
      : null

    const { current, superseded } = (typeof opts.keepReleases === 'number' && opts.keepReleases > 0)
      ? splitReleaseTrees(opts.releases || [], opts.keepReleases)
      : { current: new Set(), superseded: new Set() }

    for (const hash of allHashes) {
      const referenced = (refs[hash] && refs[hash].length > 0)
      const pinned = this.isPinned(hash)

      if (referenced || pinned || current.has(hash)) {
        kept.push(hash)
        continue
      }

      // Age gate — if a cutoff is set, only sweep trees pulled before it.
      // A tree only an older release uses is past its retention whatever its age.
      if (cutoff !== null && !superseded.has(hash)) {
        const meta = this.getMeta(hash)
        const pulledAt = (meta && typeof meta.pulledAt === 'number') ? meta.pulledAt : 0
        if (pulledAt > cutoff) {
          kept.push(hash)
          continue
        }
      }

      candidates.push(hash)
      if (!opts.dryRun) {
        fs.rmSync(this.treePath(hash), { recursive: true, force: true })
        try { fs.unlinkSync(this.metaPath(hash)) } catch {}
        removed.push(hash)
      }
    }

    // Orphan-app sweep. `apps/<name>` dirs absent from refs.json are stale
    // installs that the dependency/blob GC can never reach (their files are
    // hardlinked from deps that may already be gone, leaving sole nlink-1
    // copies). Always REPORT them; only remove when explicitly asked. Done
    // before the blob cleanup so freed hardlinks get collected this pass.
    const orphanApps = this.listOrphanApps()
    const appsRemoved = []
    if (opts.sweepOrphanApps && !opts.dryRun) {
      for (const name of orphanApps) {
        try {
          fs.rmSync(this.appPath(name), { recursive: true, force: true })
          appsRemoved.push(name)
        } catch {}
      }
    }

    // Blob layer cleanup. When a tree dir is removed, its hardlinks
    // drop the blob's nlink count. Any blob whose nlink is 1 (only the
    // blob file itself, no tree refs) is now garbage. Walk shards,
    // unlink orphans.
    let blobsRemoved = 0
    if (!opts.dryRun && fs.existsSync(this.blobsDir)) {
      for (const shard of fs.readdirSync(this.blobsDir)) {
        const shardDir = path.join(this.blobsDir, shard)
        let entries
        try { entries = fs.readdirSync(shardDir) } catch { continue }
        for (const name of entries) {
          const blobFile = path.join(shardDir, name)
          let st
          try { st = fs.statSync(blobFile) } catch { continue }
          if (st.nlink === 1) {
            try { fs.unlinkSync(blobFile); blobsRemoved++ } catch {}
          }
        }
        // Drop empty shard dirs to keep the layout tidy.
        try {
          const remaining = fs.readdirSync(shardDir)
          if (remaining.length === 0) fs.rmdirSync(shardDir)
        } catch {}
      }
    }

    return { candidates, removed, kept, blobsRemoved, orphanApps, appsRemoved }
  }

  // ── Integrity / repair ─────────────────────────────────────

  /**
   * Audit every blob: recompute its content hash and compare to the hash
   * encoded in its path (`blobs/<aa>/<rest>[.x]` → `aa+rest`). A mismatch
   * means the blob's bytes were mutated after creation — an in-place write
   * over a hardlink — and every tree hardlinking it no longer round-trips.
   *
   * @returns {{ checked: number, corrupt: Array<{ path, exec, expected, actual, ino }> }}
   */
  verifyBlobs () {
    this.init()
    const corrupt = []
    let checked = 0
    if (!fs.existsSync(this.blobsDir)) return { checked, corrupt }
    for (const shard of fs.readdirSync(this.blobsDir)) {
      const shardDir = path.join(this.blobsDir, shard)
      let names
      try { names = fs.readdirSync(shardDir) } catch { continue }
      for (const name of names) {
        if (name.includes('.tmp-')) continue
        const exec = name.endsWith('.x')
        const tail = exec ? name.slice(0, -2) : name
        const expected = shard + tail
        const blobFile = path.join(shardDir, name)
        let bytes, st
        try { bytes = fs.readFileSync(blobFile); st = fs.statSync(blobFile) } catch { continue }
        checked++
        const actual = crypto.data(bytes).toString('hex')
        if (actual !== expected) corrupt.push({ path: blobFile, exec, expected, actual, ino: st.ino })
      }
    }
    return { checked, corrupt }
  }

  // Recursively yield absolute file paths under dir (no symlink follow).
  _walkFiles (dir, cb) {
    let entries
    try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }
    for (const ent of entries) {
      const p = path.join(dir, ent.name)
      if (ent.isDirectory()) this._walkFiles(p, cb)
      else if (ent.isFile()) cb(p)
    }
  }

  /**
   * Repair a corrupt store. For every blob whose bytes no longer match its
   * content hash:
   *   1. find every dependency tree that hardlinks the corrupt inode and
   *      invalidate it (remove the tree dir + meta) so the next install
   *      re-fetches a clean copy from a peer — partial/host-specific trees
   *      are NOT false-flagged because matching is by inode, not round-trip,
   *   2. quarantine (delete) the corrupt blob so it can't poison future
   *      materializes.
   *
   * The correct bytes are, by definition, no longer on this machine, so
   * repair cannot reconstruct them — it makes the store self-consistent
   * (no poisoned blobs, no half-broken trees) and reports which trees were
   * invalidated so the operator can re-fetch / re-publish them.
   *
   * @param {object} [opts] { dryRun?: boolean }
   * @returns {{ checked, corruptBlobs, removedBlobs, invalidatedTrees, dryRun }}
   */
  repair (opts = {}) {
    this.init()
    const { checked, corrupt } = this.verifyBlobs()
    const removedBlobs = []
    const invalidatedTrees = []
    if (corrupt.length) {
      const badInodes = new Set(corrupt.map(c => c.ino))
      let treeHashes = []
      try { treeHashes = fs.readdirSync(this.dependenciesDir) } catch {}
      for (const h of treeHashes) {
        const dir = this.treePath(h)
        let affected = false
        this._walkFiles(dir, (p) => {
          if (affected) return
          let st
          try { st = fs.statSync(p) } catch { return }
          if (badInodes.has(st.ino)) affected = true
        })
        if (affected) {
          invalidatedTrees.push(h)
          if (!opts.dryRun) {
            fs.rmSync(dir, { recursive: true, force: true })
            try { fs.unlinkSync(this.metaPath(h)) } catch {}
          }
        }
      }
      for (const c of corrupt) {
        removedBlobs.push(c.path)
        if (!opts.dryRun) { try { fs.unlinkSync(c.path) } catch {} }
      }
    }
    return { checked, corruptBlobs: corrupt, removedBlobs, invalidatedTrees, dryRun: !!opts.dryRun }
  }

  // ── Stats ──────────────────────────────────────────────────

  /**
   * Directory size in bytes (recursive). Symlinks counted as their target size.
   */
  _dirSize (dir) {
    let total = 0
    try {
      for (const name of fs.readdirSync(dir)) {
        const p = path.join(dir, name)
        let stat
        try { stat = fs.lstatSync(p) } catch { continue }
        if (stat.isSymbolicLink()) continue // don't double-count through symlinks
        if (stat.isDirectory()) total += this._dirSize(p)
        else if (stat.isFile()) total += stat.size
      }
    } catch {}
    return total
  }

  stats () {
    this.init()
    const refs = this._readRefs()
    const hashes = fs.readdirSync(this.dependenciesDir)
    let totalBytes = 0
    let referencedCount = 0
    let pinnedCount = 0

    for (const hash of hashes) {
      totalBytes += this._dirSize(this.treePath(hash))
      if (refs[hash] && refs[hash].length > 0) referencedCount++
      if (this.isPinned(hash)) pinnedCount++
    }

    return {
      treeCount: hashes.length,
      referencedCount,
      unreferencedCount: hashes.length - referencedCount,
      pinnedCount,
      totalBytes
    }
  }

  listTrees () {
    this.init()
    const refs = this._readRefs()
    const hashes = fs.readdirSync(this.dependenciesDir)
    return hashes.map(hash => ({
      hash,
      size: this._dirSize(this.treePath(hash)),
      refs: refs[hash] || [],
      pinned: this.isPinned(hash),
      meta: this.getMeta(hash)
    }))
  }
}

// ── Release retention ───────────────────────────────────────────

/**
 * Every tree a release entry names: the app root, each dep's tree, and each
 * dep's per-arch prebuild trees (publish.js depEntry).
 */
export function releaseTreeHashes (entry) {
  const out = new Set()
  if (!entry) return out
  if (entry.treeHash) out.add(entry.treeHash)
  for (const d of entry.deps || []) {
    if (d && d.tree) out.add(d.tree)
    if (d && d.prebuilds) for (const h of Object.values(d.prebuilds)) if (h) out.add(h)
  }
  return out
}

/**
 * Per app, newest `keep` releases (by publishedAt) → `current`; trees ONLY in
 * older releases → `superseded`. A tree shared with a current release is current.
 */
function splitReleaseTrees (releases, keep) {
  const byApp = new Map()
  for (const e of releases) {
    if (!e || !e.name) continue
    if (!byApp.has(e.name)) byApp.set(e.name, [])
    byApp.get(e.name).push(e)
  }
  const current = new Set()
  const older = new Set()
  for (const list of byApp.values()) {
    list.sort((a, b) => (a.publishedAt === b.publishedAt ? 0 : (a.publishedAt > b.publishedAt ? -1 : 1)))
    list.forEach((e, i) => {
      for (const h of releaseTreeHashes(e)) (i < keep ? current : older).add(h)
    })
  }
  const superseded = new Set([...older].filter(h => !current.has(h)))
  return { current, superseded }
}

// ── Content-address verification ────────────────────────────────

/**
 * Verify fetched bytes hash to the advertised content hash.
 * Throws on mismatch with a descriptive error so upstream callers
 * (installTree, NetworkFetcher) can retry on a different peer.
 */
function verifyContent (expectedHash, bytes, label) {
  const actual = crypto.data(bytes).toString('hex')
  if (actual !== expectedHash) {
    throw Object.assign(
      new Error('content-hash mismatch for ' + label + ': expected ' + expectedHash.slice(0, 12) + '…, got ' + actual.slice(0, 12) + '…'),
      { code: 'EHASHMISMATCH', expected: expectedHash, actual }
    )
  }
}

/**
 * Verify fetched subtree entries hash to the advertised tree hash.
 * Same pattern — content-addressed verification is how we catch peers
 * that respond with "I don't have this" (empty entries) while the
 * advertised hash promised content.
 */
function verifyTreeEntries (expectedHash, entries, label) {
  const actual = computeTreeHash(entries)
  if (actual !== expectedHash) {
    throw Object.assign(
      new Error('tree-hash mismatch for ' + label + ': expected ' + expectedHash.slice(0, 12) + '…, got ' + actual.slice(0, 12) + '…'),
      { code: 'EHASHMISMATCH', expected: expectedHash, actual }
    )
  }
}

export { verifyContent, verifyTreeEntries }