/**
* FollowsRegistry — persistent index of active follows.
*
* Distinct from FollowRegistry (lib/follow-registry.js):
*
* FollowRegistry → the in-memory tracker store. Manages live
* HyperIssues instances under the per-source
* namespace `follow:<srvKey>:<scope>:<name>`.
*
* FollowsRegistry → the on-disk record of WHAT we follow. Survives
* daemon restart so RepoFollowers can be respawned
* on next boot without the user re-running
* `pgh repo follow`.
*
* Storage layout:
* <storage>/follows.json:
* {
* version: 1,
* repos: {
* '<sourceServerKey>|<repoName>': {
* sourceServerKey, name,
* payloadHex, // hex-encoded follow-payload buffer
* addedAt
* }
* },
* orgs: { ... same shape ... } // Phase 2
* }
*
* Atomic write via tmp + rename (same pattern as RepoPuller cursor).
* Concurrent-write protection is not required — only one daemon
* process writes this file.
*/
import fs from '#node:fs'
import path from '#node:path'
const VERSION = 1
function k (sourceServerKey, name) {
return sourceServerKey + '|' + name
}
export class FollowsRegistry {
constructor (filePath) {
if (!filePath) throw new Error('FollowsRegistry requires a file path')
this._path = filePath
this._state = this._load()
}
_load () {
try {
const raw = fs.readFileSync(this._path, 'utf8')
const parsed = JSON.parse(raw)
if (!parsed || parsed.version !== VERSION) return this._empty()
return {
version: VERSION,
repos: (parsed.repos && typeof parsed.repos === 'object') ? parsed.repos : {},
orgs: (parsed.orgs && typeof parsed.orgs === 'object') ? parsed.orgs : {}
}
} catch {
return this._empty()
}
}
_empty () { return { version: VERSION, repos: {}, orgs: {} } }
_save () {
fs.mkdirSync(path.dirname(this._path), { recursive: true })
const tmp = this._path + '.tmp'
fs.writeFileSync(tmp, JSON.stringify(this._state, null, 2))
fs.renameSync(tmp, this._path)
}
/**
* Add (or replace) a repo follow. Idempotent: re-adding the same
* (sourceServerKey, name) returns false on no-change.
*/
addRepo (sourceServerKey, name, payloadHex) {
return this._add('repos', sourceServerKey, name, payloadHex)
}
addOrg (sourceServerKey, name, payloadHex) {
return this._add('orgs', sourceServerKey, name, payloadHex)
}
_add (scope, sourceServerKey, name, payloadHex) {
if (!sourceServerKey) throw new Error('sourceServerKey required')
if (!name) throw new Error('name required')
if (!payloadHex) throw new Error('payloadHex required')
const srv = String(sourceServerKey).toLowerCase()
const key = k(srv, name)
const existing = this._state[scope][key]
if (existing && existing.payloadHex === payloadHex) return false
this._state[scope][key] = {
sourceServerKey: srv,
name,
payloadHex,
addedAt: existing ? existing.addedAt : Date.now()
}
this._save()
return true
}
removeRepo (sourceServerKey, name) {
return this._remove('repos', sourceServerKey, name)
}
removeOrg (sourceServerKey, name) {
return this._remove('orgs', sourceServerKey, name)
}
_remove (scope, sourceServerKey, name) {
const srv = String(sourceServerKey).toLowerCase()
const key = k(srv, name)
if (!this._state[scope][key]) return false
delete this._state[scope][key]
this._save()
return true
}
getRepo (sourceServerKey, name) {
const srv = String(sourceServerKey).toLowerCase()
return this._state.repos[k(srv, name)] || null
}
getOrg (sourceServerKey, name) {
const srv = String(sourceServerKey).toLowerCase()
return this._state.orgs[k(srv, name)] || null
}
listRepos () { return Object.values(this._state.repos) }
listOrgs () { return Object.values(this._state.orgs) }
}