/**
* 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
}
}