OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / lib / follow-registry.js

166 lines · javascript ref:
/**
 * 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()
  }
}