/**
* FollowRegistry — per-source HyperIssues trackers for non-mirror follow.
*
* Parallel to IssueRegistry, but for read-only follower mode:
*
* IssueRegistry → trackers the daemon may write to. Namespace
* `issues:repo:<name>` / `issues:org:<name>`. Mirror
* pre-loads bootstrap keys via deployment bundle.
*
* FollowRegistry → trackers the daemon follows read-only. Namespace
* `follow:<sourceServerKey>:repo:<name>` /
* `follow:<sourceServerKey>:org:<name>`. Bootstrap
* key MUST be provided up front (strict by design —
* no auto-derive ever).
*
* Why a separate registry and namespace
* ------------------------------------
* IssueRegistry can't host follower trackers safely. Three reasons:
*
* 1. Namespace collision. A daemon may own local repo `foo` AND
* follow a different `foo` from peer X. Both opening
* `issues:repo:foo` against different bootstrap keys = corestore
* manifest collision / apply-state crash (see
* issue-registry.js bindRepoBootstrapKey doc-comment).
* Prefixing namespace with `follow:<srvKey>:` makes collisions
* impossible by construction.
*
* 2. Different runtime constraints. IssueRegistry permits auto-derive
* (origin daemons creating fresh trackers on demand). FollowRegistry
* must NEVER auto-derive — a follower with no bootstrap key would
* mint a sibling autobase that never replicates anything.
*
* 3. Different lifecycle. IssueRegistry trackers stay open for the
* daemon's lifetime; FollowRegistry trackers are created/closed
* with `pgh repo follow/unfollow`.
*
* Storage layout (per source X, per repo R):
* corestore namespace `follow:<X-hex>:repo:<R>`
*
* Persistence: in-memory only. The list of active follows is the source
* of truth in `<storage>/follows.json` (FollowsRegistry, Phase 1).
*/
import b4a from 'b4a'
import { HyperIssues } from 'hyper-issues'
function normalizeKey (v, field) {
if (v == null) {
const err = new Error(`${field} is required`)
err.code = 'EREQUIRED'
throw err
}
if (typeof v === 'string') return v.toLowerCase()
return b4a.toString(v, 'hex')
}
function namespaceFor (scope, sourceServerKey, name) {
return 'follow:' + sourceServerKey + ':' + scope + ':' + name
}
export class FollowRegistry {
constructor (corestore) {
if (!corestore) throw new Error('FollowRegistry requires a corestore')
this._corestore = corestore
// (sourceServerKey, scope, name) → HyperIssues
// Keyed as `${srvKey}|${scope}|${name}` for Map lookup.
this._trackers = new Map()
}
_key (sourceServerKey, scope, name) {
return sourceServerKey + '|' + scope + '|' + name
}
/**
* Open (or return cached) the read-only HyperIssues tracker for a
* followed repo. Throws ETRACKER_NOT_READY if bootstrapKey is missing.
*
* Once opened, the tracker stays cached for the lifetime of this
* FollowRegistry. Re-calling with the same triple is idempotent.
* Re-calling with a different bootstrapKey for the same triple
* returns the originally-opened tracker (a follower's bootstrap key
* should never change without unfollow+refollow).
*/
async openRepo (sourceServerKey, repoName, bootstrapKey) {
return this._open('repo', sourceServerKey, repoName, bootstrapKey)
}
async openOrg (sourceServerKey, orgName, bootstrapKey) {
return this._open('org', sourceServerKey, orgName, bootstrapKey)
}
async _open (scope, sourceServerKey, name, bootstrapKey) {
if (!name) throw new Error('open' + scope + ' requires a name')
const srv = normalizeKey(sourceServerKey, 'sourceServerKey')
const cacheKey = this._key(srv, scope, name)
if (this._trackers.has(cacheKey)) return this._trackers.get(cacheKey)
if (bootstrapKey == null) {
const err = new Error(
`follow-tracker bootstrap key required for ${scope}:${name} from ${srv.slice(0, 8)}…`
)
err.code = 'ETRACKER_NOT_READY'
throw err
}
const hex = normalizeKey(bootstrapKey, 'bootstrapKey')
const tracker = new HyperIssues(this._corestore, {
namespace: namespaceFor(scope, srv, name),
bootstrapKey: hex
})
await tracker.ready()
this._trackers.set(cacheKey, tracker)
return tracker
}
/** Get a previously-opened tracker without opening it. */
getOpenRepo (sourceServerKey, repoName) {
const srv = normalizeKey(sourceServerKey, 'sourceServerKey')
return this._trackers.get(this._key(srv, 'repo', repoName)) || null
}
getOpenOrg (sourceServerKey, orgName) {
const srv = normalizeKey(sourceServerKey, 'sourceServerKey')
return this._trackers.get(this._key(srv, 'org', orgName)) || null
}
/** Close + drop a single tracker. Caller (unfollow handler) decides
* whether to do this — the per-source cores on disk are preserved
* per the never-delete-storage rule. */
async closeRepo (sourceServerKey, repoName) {
return this._close('repo', sourceServerKey, repoName)
}
async closeOrg (sourceServerKey, orgName) {
return this._close('org', sourceServerKey, orgName)
}
async _close (scope, sourceServerKey, name) {
const srv = normalizeKey(sourceServerKey, 'sourceServerKey')
const cacheKey = this._key(srv, scope, name)
const tracker = this._trackers.get(cacheKey)
if (!tracker) return false
this._trackers.delete(cacheKey)
try { await tracker.close() } catch {}
return true
}
/** Snapshot of all open follows. Each entry: { sourceServerKey, scope, name, keyHex }. */
list () {
const out = []
for (const [k, tracker] of this._trackers) {
const [sourceServerKey, scope, name] = k.split('|')
out.push({ sourceServerKey, scope, name, keyHex: tracker.keyHex })
}
return out
}
async close () {
for (const tracker of this._trackers.values()) {
try { await tracker.close() } catch {}
}
this._trackers.clear()
}
}