OverviewFilesCommitsIssuesDocsReleasesPeers

pgh / lib / published-index.js

272 lines · javascript ref:
/**
 * published-index — local record of apps this publisher has released.
 *
 * P.10 Phase 0.5d. Used for conflict detection at `pgh publish` time:
 * when you publish a new app, we check every shared dep name against
 * already-published apps and hard-block if versions or tree hashes
 * disagree.
 *
 * Retention policy (retention-policy branch, 2026-05-13):
 *   - Default ephemeral: at most MAX_VERSIONS recent versions per app
 *     are kept. Older entries are pruned automatically on .add().
 *   - Opt-in permanence: a version can be pinned via .pinVersion(),
 *     which exempts it from MAX_VERSIONS pruning.
 *   - `_sourcePath` (publisher-local absolute path to a dep's source
 *     directory) is stripped on store. It's debug-only and bloats the
 *     file with strings like /tmp/pear-git-ci-<hash>/stage/... that
 *     have no value to anyone reading the index later.
 *
 * Location: ~/.config/pgh/published-index.json
 *
 * Schema:
 * {
 *   publishedAt,
 *   apps: {
 *     <appName>: {
 *       versions: [
 *         { version, rootTreeHash, lockfile, acceptedConflicts?,
 *           publishedAt, pinned? }
 *       ]
 *     }
 *   }
 * }
 */

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

export const DEFAULT_PATH = path.join(homedir(), '.config', 'pgh', 'published-index.json')

// Default retention: latest-only. The deploy server only ever needs
// to serve the latest version of each app — installs target latest,
// rollback uses local hardlinks, older versions are dead weight on
// disk. Explicit opt-in for more via .pinVersion() or env override.
//
// 2026-05-13: changed from 3 → 1 after the dedup audit found that
// historical versions were the dominant bloat source (22 GB on
// tabertron held by 9 hyper-tps rootTrees that differ by ~1% each).
// See docs/2026-05-13-deploy-latest-only.md.
const ENV_MAX = parseInt(process.env.PGH_INDEX_MAX_VERSIONS, 10)
export const DEFAULT_MAX_VERSIONS = Number.isFinite(ENV_MAX) && ENV_MAX > 0 ? ENV_MAX : 1

export class PublishedIndex {
  constructor (opts = {}) {
    this.path = opts.path || DEFAULT_PATH
    this.maxVersions = (typeof opts.maxVersions === 'number' && opts.maxVersions > 0)
      ? opts.maxVersions
      : DEFAULT_MAX_VERSIONS
    this._data = null
  }

  _ensureDir () {
    fs.mkdirSync(path.dirname(this.path), { recursive: true })
  }

  load () {
    if (this._data) return this._data
    if (!fs.existsSync(this.path)) {
      this._data = { publishedAt: null, apps: {} }
      return this._data
    }
    try {
      this._data = JSON.parse(fs.readFileSync(this.path, 'utf8'))
      if (!this._data.apps) this._data.apps = {}
    } catch {
      this._data = { publishedAt: null, apps: {} }
    }
    return this._data
  }

  save () {
    this._ensureDir()
    fs.writeFileSync(this.path, JSON.stringify(this._data || { apps: {} }, null, 2))
  }

  /**
   * Strip publisher-local fields from a lockfile before storing.
   * `_sourcePath` records the absolute filesystem path on the publisher
   * machine — useful for the publish process itself, useless (and
   * sensitive) once persisted to the index.
   */
  _sanitizeLockfile (lockfile) {
    if (!lockfile) return lockfile
    const sanitized = { ...lockfile }
    if (Array.isArray(sanitized.deps)) {
      sanitized.deps = sanitized.deps.map(d => {
        const { _sourcePath, ...rest } = d
        return rest
      })
    }
    return sanitized
  }

  /**
   * Add a successful publish to the index. Trims to MAX_VERSIONS, preserving
   * any pinned entries.
   *
   * @param {object} lockfile - as produced by computeLockfile
   */
  add (lockfile) {
    const data = this.load()
    if (!data.apps[lockfile.name]) {
      data.apps[lockfile.name] = { versions: [] }
    }
    const entry = {
      version: lockfile.version,
      rootTreeHash: lockfile.rootTreeHash,
      lockfile: this._sanitizeLockfile(lockfile),
      acceptedConflicts: lockfile.acceptedConflicts || [],
      publishedAt: lockfile.publishedAt,
      pinned: false
    }

    const versions = data.apps[lockfile.name].versions
    const existing = versions.findIndex(v => v.version === lockfile.version)
    if (existing !== -1) {
      // Re-publish — preserve pinned state, replace rest.
      entry.pinned = versions[existing].pinned || false
      versions[existing] = entry
    } else {
      versions.push(entry)
    }

    // Trim to retention limit, preserving pinned versions. Pinned ones
    // never count toward the cap — they're explicit "keep forever"
    // markers. Unpinned versions are sorted by publishedAt (newest
    // first) and we keep up to maxVersions of them.
    data.apps[lockfile.name].versions = this._trim(versions)

    data.publishedAt = lockfile.publishedAt
    this.save()
  }

  /**
   * Apply retention policy to a versions array. Pinned versions are
   * always kept; unpinned versions are sorted newest-first and
   * truncated to maxVersions. Exposed for the migration script.
   */
  _trim (versions) {
    const pinned = versions.filter(v => v.pinned)
    const unpinned = versions
      .filter(v => !v.pinned)
      .sort((a, b) => {
        // Newer first. Fall back to version string compare if timestamps
        // are missing or equal (e.g. CI publishes within the same ms).
        const ta = Date.parse(a.publishedAt || '') || 0
        const tb = Date.parse(b.publishedAt || '') || 0
        if (tb !== ta) return tb - ta
        return (b.version || '').localeCompare(a.version || '')
      })
      .slice(0, this.maxVersions)

    // Re-emit in chronological order so the file stays human-readable.
    return [...pinned, ...unpinned].sort((a, b) => {
      const ta = Date.parse(a.publishedAt || '') || 0
      const tb = Date.parse(b.publishedAt || '') || 0
      return ta - tb
    })
  }

  /**
   * Mark a specific version as pinned — exempt from retention trimming.
   * Returns true if a matching entry was found and updated.
   */
  pinVersion (name, version) {
    const data = this.load()
    if (!data.apps[name]) return false
    const v = data.apps[name].versions.find(x => x.version === version)
    if (!v) return false
    v.pinned = true
    this.save()
    return true
  }

  /**
   * Remove the pin on a version. Note: an unpinned version may now be
   * eligible for trim on the NEXT add() — this method does not trim.
   */
  unpinVersion (name, version) {
    const data = this.load()
    if (!data.apps[name]) return false
    const v = data.apps[name].versions.find(x => x.version === version)
    if (!v) return false
    v.pinned = false
    this.save()
    return true
  }

  /**
   * Rewrite the on-disk file applying retention + sanitization to every
   * app at once. Used as a one-shot migration to shrink a legacy index
   * that has both bloat sources: (a) >MAX_VERSIONS versions per app,
   * (b) publisher-local `_sourcePath` strings in each dep entry.
   * Idempotent.
   */
  rewriteWithRetention () {
    const data = this.load()
    for (const [name, app] of Object.entries(data.apps)) {
      const trimmed = this._trim(app.versions || [])
      // Strip _sourcePath from each surviving version's lockfile.
      app.versions = trimmed.map(v => ({
        ...v,
        lockfile: v.lockfile ? this._sanitizeLockfile(v.lockfile) : v.lockfile
      }))
      if (app.versions.length === 0) delete data.apps[name]
    }
    this.save()
  }

  /**
   * All published lockfiles across every app+version combo.
   * Used by the conflict detector to scan dep hashes.
   */
  allLockfiles () {
    const data = this.load()
    const out = []
    for (const [appName, app] of Object.entries(data.apps)) {
      for (const v of app.versions) {
        out.push({ appName, version: v.version, lockfile: v.lockfile })
      }
    }
    return out
  }

  /**
   * List everything we've published (summary).
   */
  list () {
    const data = this.load()
    const out = []
    for (const [name, app] of Object.entries(data.apps)) {
      for (const v of app.versions) {
        out.push({
          name,
          version: v.version,
          rootTreeHash: v.rootTreeHash,
          publishedAt: v.publishedAt,
          pinned: !!v.pinned
        })
      }
    }
    return out
  }

  /**
   * Remove an app (or specific version) from the index.
   */
  remove (name, version = null) {
    const data = this.load()
    if (!data.apps[name]) return false
    if (version === null) {
      delete data.apps[name]
    } else {
      data.apps[name].versions = data.apps[name].versions.filter(v => v.version !== version)
      if (data.apps[name].versions.length === 0) delete data.apps[name]
    }
    this.save()
    return true
  }
}