OverviewFilesCommitsIssuesDocsReleasesPeers

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

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