/**
* IssueRegistry — per-repo + per-org HyperIssues instances.
*
* Every tracker is scoped to a repository OR an organization. There is
* no global issue tracker — global state was retired in P.2. Each scope
* gets its own HyperIssues instance (and therefore its own Autobase +
* bootstrap key), created on demand and cached.
*
* Scope layout (namespaces):
* issues:repo:<repoName> — one per repo
* issues:org:<orgName> — one per org
*
* Peers opt into a shared instance by passing the matching bootstrapKey
* via `mountOpts.issueRepos[<repoName>]` or `mountOpts.issueOrgs[<orgName>]`.
*/
import fs from '#node:fs'
import path from '#node:path'
import b4a from 'b4a'
import Corestore from 'corestore'
import { HyperIssues, joinRepoPairing, decodeRoomPairingInvite } from 'hyper-issues'
// pear-git #3 Increment 2c — keet-style repair-on-open. On a standalone genesis
// peer, a tracker whose autobase manifest was group-migrated (indexer-adds it
// can't reconcile incrementally) crash-loops. Keet lives with the SAME
// shared-corestore architecture by calling base.repair() (forceFastForward) on
// room open, gated by a repair-version (keet-core index.js:305
// internalEnsureRepaired). We do the same: on tracker open, if the repo hasn't
// been repaired at REPAIR_VERSION yet, force-fast-forward it once so it rewinds
// to a consistent checkpoint and FFs onto the group's signed state instead of
// draining incrementally into the null-view crash. No-op on healthy trackers.
// BUMP this when a new broken class needs a one-time repair-on-open sweep.
const REPAIR_VERSION = 1
export class IssueRegistry {
constructor (corestore, opts = {}) {
this._corestore = corestore
this._repoBootstrapKeys = opts.repoBootstrapKeys || {}
this._orgBootstrapKeys = opts.orgBootstrapKeys || {}
this._repos = new Map() // repoName -> HyperIssues
this._orgs = new Map() // orgName -> HyperIssues
// In-flight open dedup. Concurrent first-time getForRepo/getForOrg calls
// (e.g. the gip producer firing from ref-update-observed while an issue
// page hits the same scope) must NOT each `new HyperIssues(...)` on the
// same corestore namespace — two autobases on one namespace deadlock on
// the corestore manifest. Share the open promise instead.
this._reposOpening = new Map() // repoName -> Promise<HyperIssues>
this._orgsOpening = new Map() // orgName -> Promise<HyperIssues>
// Persisted tracker index. When indexPath is set, every tracker ever
// opened on this daemon (keyed by name → keyHex) is recorded to
// disk. keys() merges the on-disk index with the in-memory cache so
// `device-invite --mirror` bundles a complete set of tracker
// bootstrap keys — not just whatever happened to be lazily loaded
// in this process's memory at invite time. (Yesterday's stortron
// rollout hit this bug: deploymentBundle.issueRegistry.repos was
// {} because no one had touched an issue page since daemon start.)
this._indexPath = opts.indexPath || null
this._index = this._loadIndex()
// Strict-bootstrap mode: when true, getFor* refuses to auto-derive a
// local autobase root if no bootstrap key is known yet. Used on
// mirrors so a page-hit before the deployment bundle / event log
// delivers the canonical tracker key never mints a sibling autobase.
// Sibling autobases sharing the corestore-derived local writer with
// an eventual canonical autobase are the source of the apply-state
// crash documented in test/tracker-strict-bootstrap-e2e.test.js.
this._requireBootstrap = !!opts.requireBootstrap
// Linked-core org trackers (#42). When set, getForOrg(name) wraps the
// org's record Room (which carries issue ops via composeSchemas) with
// a non-owning HyperIssues instead of opening a separate
// issues:org:<name> autobase. So org issues replicate WITH the org —
// no separate tracker key to convey to peers/mirrors. fn: name → Room|null.
this._orgRoomProvider = opts.orgRoomProvider || null
// Self-heal (pear-git #3, Increment 2): when a per-repo tracker autobase
// emits 'error' (a stale local state that can't reconcile a group manifest
// migration — the standalone-peer failure that full-replica mirrors never
// hit), reopen it under a FRESH corestore namespace so it re-syncs the full
// state from peers like a clean mirror, instead of crash-looping on the
// stale copy. `resyncGen[repo]` (persisted in the index) suffixes the
// namespace; each error bumps it. The old namespace stays on disk as a
// backup (nothing deleted). `onResync(repoName)` lets the daemon re-attach
// the RefReconciler to the fresh tracker.
this._onResync = typeof opts.onResync === 'function' ? opts.onResync : null
this._resyncing = new Set()
// forceResync (clean re-join) side stores: a FRESH corestore per resync'd
// tracker. A fresh namespace does NOT shed a poisoned local writer —
// autobase's local key is derived from the corestore primaryKey (shared by
// .namespace()), NOT the namespace suffix (proven in
// force-resync-participation.test.js). Only a fresh corestore (fresh
// primaryKey) gives a clean local writer. Default: <indexDir>/tracker-resync.
this._sideStoreDir = opts.sideStoreDir ||
(this._indexPath ? path.join(path.dirname(this._indexPath), 'tracker-resync') : null)
this._sideStores = new Map() // repoName -> Corestore (for replication + close)
}
// The corestore namespace for a repo tracker, suffixed by its resync
// generation. gen 0 = canonical namespace; each 'error' bumps it so the next
// open lands on a clean namespace (fresh local writer, no stale blocks) that
// re-materializes the view from peers. #3.
_repoNamespace (repoName) {
const g = (this._index.resyncGen && this._index.resyncGen[repoName]) || 0
return g > 0 ? 'issues:repo:' + repoName + '#r' + g : 'issues:repo:' + repoName
}
// Triggered when a repo tracker's autobase emits 'error'. Bump the
// generation, drop the broken tracker, and hand off to the daemon (onResync)
// to reopen under the fresh namespace + re-attach the reconciler. Guarded so
// repeated errors for one repo coalesce into a single resync. #3.
async _resyncRepo (repoName) {
if (!repoName || this._resyncing.has(repoName)) return
this._resyncing.add(repoName)
try {
if (!this._index.resyncGen) this._index.resyncGen = {}
const cur = this._index.resyncGen[repoName] || 0
// Safety cap: a fresh namespace should materialize cleanly (like a mirror
// that never hit this). If it STILL errors after MAX_RESYNC fresh opens,
// stop — leave the tracker isolated (Increment 1: not fatal, daemon stays
// up, that one repo's issues/refs unavailable) rather than resync-loop and
// spawn namespaces forever. #3.
const MAX_RESYNC = 3
if (cur >= MAX_RESYNC) {
// eslint-disable-next-line no-console
console.error('[issue-registry] repo ' + repoName + ' still erroring after ' + cur + ' fresh-namespace resync(s) — leaving isolated, no further resync')
return
}
this._index.resyncGen[repoName] = cur + 1
this._saveIndex()
const old = this._repos.get(repoName)
this._repos.delete(repoName)
if (old) { try { await old.close() } catch {} }
// eslint-disable-next-line no-console
console.error('[issue-registry] self-heal: repo ' + repoName + ' tracker errored — reopening under fresh namespace ' + this._repoNamespace(repoName))
if (this._onResync) { try { await this._onResync(repoName) } catch {} }
} finally {
this._resyncing.delete(repoName)
}
}
_loadIndex () {
if (!this._indexPath) return { repos: {}, orgs: {}, resyncGen: {}, repairedVersion: {}, sideStore: {} }
try {
const raw = fs.readFileSync(this._indexPath, 'utf8')
const parsed = JSON.parse(raw)
return {
repos: (parsed && parsed.repos) || {},
orgs: (parsed && parsed.orgs) || {},
resyncGen: (parsed && parsed.resyncGen) || {},
repairedVersion: (parsed && parsed.repairedVersion) || {},
// Persisted forceResync side stores: repo → gen. When set, getForRepo
// opens the tracker from <sideStoreDir>/<repo>-r<gen> (fresh corestore,
// clean local writer) instead of the poisoned main-corestore tracker —
// so the heal SURVIVES a daemon restart.
sideStore: (parsed && parsed.sideStore) || {}
}
} catch {
return { repos: {}, orgs: {}, resyncGen: {}, repairedVersion: {}, sideStore: {} }
}
}
// Increment 2c: keet-style repair-on-open. Force-fast-forward a repo tracker
// once per REPAIR_VERSION (persisted) so one that can't incrementally reconcile
// a group manifest migration rewinds to a consistent checkpoint and FFs onto
// the group's signed state (via the daemon's live replication) instead of
// crash-looping. Runs on the tracker's OWN instance right after ready(), before
// the async drain that crashes — matching keet's internalEnsureRepaired. No-op
// (or a cheap FF) on healthy trackers. Errors are swallowed: repair is
// best-effort recovery, and the Room 'error' handler (Inc1) remains the
// backstop if it can't heal.
async _repairOnOpen (repoName, tracker) {
if (!this._index.repairedVersion) this._index.repairedVersion = {}
const cur = this._index.repairedVersion[repoName] || 0
if (cur >= REPAIR_VERSION) return
const base = tracker && tracker.base
try {
if (base && typeof base.repair === 'function') {
await base.repair()
// eslint-disable-next-line no-console
console.error('[issue-registry] repair-on-open: force-fast-forwarded ' + repoName + ' (REPAIR_VERSION ' + REPAIR_VERSION + ')')
}
} catch (err) {
// eslint-disable-next-line no-console
console.error('[issue-registry] repair-on-open ' + repoName + ' failed (Room error handler remains backstop): ' + (err && err.message))
}
// Mark repaired regardless — one attempt per version; if it didn't heal, the
// Inc1 error handler isolates it and we bump REPAIR_VERSION to retry.
this._index.repairedVersion[repoName] = REPAIR_VERSION
this._saveIndex()
}
/**
* Force a CLEAN RE-JOIN of one repo tracker (the canonical trigger the auto
* self-heal lacks). The auto path (`_resyncRepo`) only fires on a base
* 'error'; a standalone peer whose tracker is stuck but NOT erroring (its
* poisoned local writer materializes a frozen view without throwing) has no
* way to recover.
*
* The stuck tracker's poisoned local writer lives in the MAIN corestore, found
* by key. A fresh NAMESPACE does not help: autobase's local writer key is
* derived from the corestore primaryKey (shared across `.namespace()`), NOT
* the namespace suffix — proven in force-resync-participation.test.js (a
* `#r1` namespace re-derived the identical local key). So `forceResync` opens
* the tracker in a FRESH per-tracker CORESTORE (its own primaryKey → a clean
* local writer with no poisoned apply context) that replicates the group's
* healthy state as a READ-ONLY replica — exactly the recovery proven in
* broken-tracker-rejoin-recovery.test.js.
*
* Read-only is the autobase golden rule: the fresh writer is not yet in the
* tracker's writer set. To restore WRITE participation a current indexer must
* `addWriter` the fresh local key (`repo-add-writer` / `pgh repo grant-writer`)
* — the same admission every org peer goes through, and keet's addDevice shape.
*
* Returns the re-joined tracker (whose `._sideStore` the daemon must wire into
* swarm replication). The old poisoned cores stay on disk (nothing deleted).
*/
async forceResync (repoName) {
if (!repoName) throw new Error('forceResync requires a repoName')
if (!this._sideStoreDir) throw new Error('forceResync requires sideStoreDir (per-tracker fresh corestore)')
const bootstrapKey = this._repoBootstrapKeys[repoName] || this._index.repos[repoName] || null
if (!bootstrapKey) throw new Error('forceResync: no bootstrap key known for ' + repoName)
if (!this._index.resyncGen) this._index.resyncGen = {}
if (!this._index.repairedVersion) this._index.repairedVersion = {}
const gen = (this._index.resyncGen[repoName] || 0) + 1
this._index.resyncGen[repoName] = gen
// PERSIST the side-store choice so getForRepo opens from it on the NEXT boot
// too — the heal must SURVIVE a daemon restart (otherwise boot re-opens the
// poisoned main-corestore tracker and reverts the fix).
if (!this._index.sideStore) this._index.sideStore = {}
this._index.sideStore[repoName] = gen
// Suppress the Inc2c repair-on-open canonical reset + the failing repair.
this._index.repairedVersion[repoName] = REPAIR_VERSION
this._saveIndex()
// Capture the doorbell VOUCHER: the MAIN-CORESTORE local writer, which is
// reliably in the group's writer set (this peer's ORIGINAL writer key — on a
// demoted-genesis peer, the `demote-genesis-*` entry) and which this peer
// controls. NOT a previous SIDE-STORE key: those are fresh per resync and may
// never have been admitted (an unadmitted voucher fails the apply check). The
// autobase local key is primaryKey-derived (namespace-independent), so a
// throwaway main-corestore tracker's base.local IS that original writer.
// Best-effort: no voucher → re-join still valid, just no doorbell (manual
// repo-add-writer fallback).
// Capture the doorbell VOUCHER from the OLD tracker's local writer, BEFORE
// closing it. In steady state `old` is the PREVIOUS side-store tracker (its
// corestore opens cleanly), whose key is a current writer once it was admitted
// — a valid voucher for rotating to the new key. The doorbell apply gates on
// writer-membership, so an unadmitted voucher (e.g. the very FIRST heal from a
// fully poisoned state, before any admission) is safely rejected → manual
// repo-add-writer for that first round, then subsequent re-joins auto-admit.
// We do NOT open the poisoned main-corestore tracker — its base.local is
// unavailable after the apply error (proven live: probe hits the crash).
const old = this._repos.get(repoName)
this._repos.delete(repoName)
let voucherKeyPair = null
try {
const kp = old && old.base && old.base.local && old.base.local.keyPair
if (kp && kp.secretKey) voucherKeyPair = { publicKey: b4a.from(kp.publicKey), secretKey: b4a.from(kp.secretKey) }
} catch {}
if (old) { try { await old.close() } catch {} }
const prevSide = this._sideStores.get(repoName)
if (prevSide) { try { await prevSide.close() } catch {} this._sideStores.delete(repoName) }
// Fresh corestore = fresh primaryKey = clean local writer.
const tracker = await this._openSideStoreTracker(repoName, gen, bootstrapKey)
tracker._voucherKeyPair = voucherKeyPair // daemon rings the doorbell with this
this._repos.set(repoName, tracker)
this._recordTracker('repos', repoName, tracker.keyHex)
// eslint-disable-next-line no-console
console.error('[issue-registry] forceResync: repo ' + repoName + ' re-joined in FRESH corestore (gen ' + gen + ')')
return tracker
}
// Open a repo tracker from its dedicated forceResync side corestore
// (<sideStoreDir>/<repo>-r<gen>): fresh primaryKey → clean local writer.
// Shared by forceResync (create) + getForRepo (reopen on boot). Caches the
// side store in _sideStores; the daemon wires it into swarm replication
// (mount + activateRoom) via `_index.sideStore` at boot + on repo-resync.
async _openSideStoreTracker (repoName, gen, bootstrapKey) {
const sidePath = path.join(this._sideStoreDir, repoName + '-r' + gen)
const sideStore = new Corestore(sidePath)
await sideStore.ready()
const tracker = new HyperIssues(sideStore, {
namespace: 'issues:repo:' + repoName,
bootstrapKey
})
await tracker.ready()
tracker._sideStore = sideStore
if (tracker.room && typeof tracker.room.on === 'function') {
tracker.room.on('base-error', () => { this._resyncRepo(repoName).catch(() => {}) })
}
this._sideStores.set(repoName, sideStore)
return tracker
}
_saveIndex () {
if (!this._indexPath) return
try {
fs.mkdirSync(path.dirname(this._indexPath), { recursive: true })
fs.writeFileSync(this._indexPath, JSON.stringify(this._index, null, 2))
} catch {}
}
_recordTracker (scope, name, keyHex) {
if (!this._indexPath) return
if (!keyHex) return
if (this._index[scope][name] === keyHex) return
this._index[scope][name] = keyHex
this._saveIndex()
}
/**
* Get (creating if needed) the HyperIssues tracker for a repository.
*
* Strict-bootstrap mode (`requireBootstrap: true` at construction):
* if no bootstrap key is known for `repoName`, throws ETRACKER_NOT_READY
* instead of auto-deriving a sibling root. Caller (RPC handler / SSR
* page) translates to a "syncing — bootstrap not yet replicated"
* response. The bootstrap arrives via deployment bundle on boot or
* `bindRepoBootstrapKey` after RepoEventLog delivery; the next call
* succeeds.
*/
async getForRepo (repoName) {
if (!repoName) throw new Error('getForRepo requires a repoName')
if (this._repos.has(repoName)) return this._repos.get(repoName)
if (this._reposOpening.has(repoName)) return this._reposOpening.get(repoName)
const bootstrapKey = this._repoBootstrapKeys[repoName] || this._index.repos[repoName] || null
if (!bootstrapKey && this._requireBootstrap) {
const err = new Error(`tracker bootstrap key not yet known for repo:${repoName}`)
err.code = 'ETRACKER_NOT_READY'
throw err
}
const opening = (async () => {
// Durable forceResync heal: if this repo was re-joined into a dedicated
// side corestore (persisted in _index.sideStore), reopen from THERE — a
// clean local writer — never the poisoned main-corestore tracker. This is
// what makes the heal survive a daemon restart. The daemon wires the side
// store into swarm replication at boot (see _index.sideStore consumer).
const sideGen = this._index.sideStore && this._index.sideStore[repoName]
if (sideGen) {
const tracker = await this._openSideStoreTracker(repoName, sideGen, bootstrapKey)
this._repos.set(repoName, tracker)
this._recordTracker('repos', repoName, tracker.keyHex)
return tracker
}
// Increment 2c: if a repair is due and Inc2 left a bumped resyncGen (a
// fresh-namespace dead-end that never healed), reset to the canonical
// namespace so repair operates on the REAL tracker (its genesis writer +
// authored state), the way keet repairs a room's own base — not an empty
// sibling namespace.
const dueRepair = ((this._index.repairedVersion && this._index.repairedVersion[repoName]) || 0) < REPAIR_VERSION
if (dueRepair && this._index.resyncGen && this._index.resyncGen[repoName]) {
this._index.resyncGen[repoName] = 0
this._saveIndex()
}
const tracker = new HyperIssues(this._corestore, {
namespace: this._repoNamespace(repoName),
bootstrapKey
})
await tracker.ready()
// Increment 2c: keet-style repair-on-open. Force-fast-forward once per
// REPAIR_VERSION, immediately after ready() and BEFORE the async drain that
// crash-loops a migration-stuck tracker, so it rewinds to a consistent
// checkpoint and FFs onto the group state via the daemon's live
// replication. No-op on healthy trackers.
await this._repairOnOpen(repoName, tracker)
// Self-heal wiring (#3): a tracker autobase 'error' (isolated by the Room
// handler, re-emitted as 'base-error') triggers a fresh-namespace reopen.
// The _resyncRepo guard + its internal awaits keep the reopen out of the
// error emit's synchronous context.
if (tracker.room && typeof tracker.room.on === 'function') {
tracker.room.on('base-error', () => { this._resyncRepo(repoName).catch(() => {}) })
}
this._repos.set(repoName, tracker)
this._recordTracker('repos', repoName, tracker.keyHex)
return tracker
})()
this._reposOpening.set(repoName, opening)
try {
return await opening
} finally {
this._reposOpening.delete(repoName)
}
}
/**
* Open a repo tracker for a repair() attempt, TOLERATING the base 'error'
* (the manifest-migration null-view crash) instead of rejecting like
* getForRepo does. Returns { tracker, baseErr } so the caller can invoke
* base.repair() (force fast-forward — keet's canonical recovery for this
* class) while the daemon is LIVE and peers are reachable, so the FF has the
* group's signed head to fast-forward onto. Does NOT cache in _repos (this is
* a recovery probe, not the serving path). #3 Increment 2c.
*/
async openTrackerForRepair (repoName) {
if (!repoName) return { error: 'repoName required' }
if (this._repos.has(repoName)) return { tracker: this._repos.get(repoName), baseErr: null }
const bootstrapKey = this._repoBootstrapKeys[repoName] || this._index.repos[repoName] || null
if (!bootstrapKey) return { error: 'no bootstrap key known for ' + repoName }
const tracker = new HyperIssues(this._corestore, {
namespace: this._repoNamespace(repoName),
bootstrapKey
})
let baseErr = null
if (tracker.room && typeof tracker.room.on === 'function') {
tracker.room.on('base-error', (e) => { baseErr = e })
}
try { await tracker.ready() } catch (e) { baseErr = baseErr || e }
return { tracker, baseErr }
}
/**
* Bind (or replace) the bootstrap key for a per-repo issue tracker.
*
* Used by a peer's RepoSync when it learns a new repo's issue-tracker
* key from an origin peer's RepoEventLog. The next call to
* `getForRepo(repoName)` instantiates the tracker against the provided
* key, joining the origin's Autobase.
*
* If a tracker is already open with a different root, the bind is a
* no-op — the in-process tracker stays on its existing root. The new
* key is NOT applied at runtime: closing+reopening an autobase against
* a different root in the same corestore namespace leaves the
* namespace's auto-derived local writer carrying blocks under the
* old apply context, which crashes the new autobase's apply at
* `apply-state.js`. The strict-bootstrap mode prevents this scenario
* from arising on mirrors; if you reach this branch on a primary or a
* legacy mirror with a sibling root, the recovery is daemon restart
* (next boot picks up the new key from `_index` / bundle) or, for a
* corrupted corestore, re-pair via `pgh device join`.
*
* Returns true if the key was newly set, false on no-op.
*/
bindRepoBootstrapKey (repoName, bootstrapKey) {
return this._bind('repos', repoName, bootstrapKey)
}
/** Same as bindRepoBootstrapKey but for per-org trackers. */
bindOrgBootstrapKey (orgName, bootstrapKey) {
return this._bind('orgs', orgName, bootstrapKey)
}
/**
* Internal: shared bind implementation. No-ops if a tracker is
* already open under this name (regardless of whether the bootstrap
* matches) — see bindRepoBootstrapKey doc-comment for why we don't
* close+reopen at runtime.
*/
_bind (scope, name, bootstrapKey) {
if (!name) return false
const hex = typeof bootstrapKey === 'string'
? bootstrapKey
: (bootstrapKey && bootstrapKey.toString
? bootstrapKey.toString('hex')
: null)
if (!hex) return false
const liveMap = scope === 'orgs' ? this._orgs : this._repos
const keyMap = scope === 'orgs' ? this._orgBootstrapKeys : this._repoBootstrapKeys
if (liveMap.has(name)) return false
if (keyMap[name] === hex) return false
keyMap[name] = hex
return true
}
/**
* Get (creating if needed) the HyperIssues tracker for an organization.
* Strict-bootstrap behavior: see getForRepo doc-comment.
*/
async getForOrg (orgName) {
if (!orgName) throw new Error('getForOrg requires an orgName')
if (this._orgs.has(orgName)) return this._orgs.get(orgName)
if (this._orgsOpening.has(orgName)) return this._orgsOpening.get(orgName)
const opening = (async () => {
// Linked-core (#42): if the org's record Room is mounted, its issues
// live in that autobase. Wrap it (non-owning) so org issues replicate
// WITH the org — no separate issues:org:<name> autobase.
if (this._orgRoomProvider) {
const room = this._orgRoomProvider(orgName)
if (room) {
const tracker = new HyperIssues(this._corestore, { room, ownsRoom: false })
await tracker.ready()
this._orgs.set(orgName, tracker)
// Record the tracker key in the index (#123) — under linked-core the
// tracker IS the org room, so this is the org-record autobase key.
// Surfacing it in `keys()` makes dump-keys / device-invite bundles /
// resync-from-origin include the org's issues like a repo tracker.
this._recordTracker('orgs', orgName, tracker.keyHex)
return tracker
}
}
const bootstrapKey = this._orgBootstrapKeys[orgName] || this._index.orgs[orgName] || null
if (!bootstrapKey && this._requireBootstrap) {
const err = new Error(`tracker bootstrap key not yet known for org:${orgName}`)
err.code = 'ETRACKER_NOT_READY'
throw err
}
const tracker = new HyperIssues(this._corestore, {
namespace: 'issues:org:' + orgName,
bootstrapKey
})
await tracker.ready()
this._orgs.set(orgName, tracker)
this._recordTracker('orgs', orgName, tracker.keyHex)
return tracker
})()
this._orgsOpening.set(orgName, opening)
try {
return await opening
} finally {
this._orgsOpening.delete(orgName)
}
}
// ── Per-repo-autobase invites / join / enroll (issue #35) ─────
//
// A repo tracker is already its own autobase, so scope isolation is
// structural: a peer that joins ONE repo via an invite replicates only
// that repo's autobase. These mirror the HyperOrgs org-invite surface
// (createOrgInvite/joinOrg/enrollFromInvite/listOrgKeys), delegating to
// the per-instance HyperIssues invite primitives (createInvite /
// enrollFromInvite, shared via hyper-rooms).
/**
* Mint a blind-pairing invite for one repo's issue tracker (P2P; the
* invite code is the whole UX). A joiner pairs over the swarm and, for
* contributor+, is addWritered DURING pairing — no HTTP, no separate
* enroll. Requires the daemon's shared swarm.
* @returns {Promise<{ invitation: string, close: () => Promise<void> }>}
*/
async createRepoInvite (repoName, opts = {}) {
if (!opts.swarm) throw new Error('createRepoInvite requires { swarm } (P2P pairing transport)')
const tracker = await this.getForRepo(repoName)
return tracker.createInvite({ name: repoName, role: opts.role || 'reader', swarm: opts.swarm })
}
/**
* Consume a repo invite via blind-pairing: mount ONLY that repo's tracker
* over the swarm and, for contributor+, become a writer DURING pairing.
* The mounted tracker is registered + persisted under the canonical
* `issues:repo:<name>` namespace (so getForRepo re-derives the same local
* writer key across restarts). Requires the daemon's shared swarm.
* @returns {Promise<{ name, role }>}
*/
async joinRepo (invitation, opts = {}) {
if (!opts.swarm) throw new Error('joinRepo requires { swarm } (P2P pairing transport)')
const dec = decodeRoomPairingInvite(invitation)
if (!dec.meta || dec.meta.kind !== 'repo') throw new Error('invite is not a repo pairing invite')
const name = dec.meta.name
if (!name) throw new Error('repo pairing invite missing name (cannot key the tracker)')
if (this._repos.has(name)) return { name, role: dec.role }
this.bindRepoBootstrapKey(name, dec.bootstrapKey)
const { tracker, role } = await joinRepoPairing(invitation, {
corestore: this._corestore,
swarm: opts.swarm,
namespace: 'issues:repo:' + name
})
this._repos.set(name, tracker)
this._recordTracker('repos', name, tracker.keyHex)
return { name, role }
}
/**
* Re-register a persisted repo-invite pairing member after a restart so an
* already-shared invite keeps working. Mounts the tracker if needed.
* @returns {Promise<void>}
*/
async resumeRepoPairing (repoName, descriptor, swarm) {
if (!swarm) throw new Error('resumeRepoPairing requires { swarm }')
const tracker = await this.getForRepo(repoName)
tracker.resumePairing(descriptor, swarm)
}
/**
* Every repo tracker key this daemon knows about (live + persisted +
* bootstrap-carried), as [{ name, bootstrapKey }]. The primary exposes
* this so a mirror can mount each → full replica (mirror mode). Peers
* never call this — they only get specific invites (peer mode), which
* is what prevents the post-mortem leak.
* @returns {Promise<Array<{ name, bootstrapKey }>>}
*/
async listRepoKeys () {
const { repos } = await this.keys()
return Object.entries(repos).map(([name, bootstrapKey]) => ({ name, bootstrapKey }))
}
/**
* Names of repos that have active trackers (instantiated this process).
*/
listRepos () { return Array.from(this._repos.keys()) }
/**
* Names of orgs that have active trackers (instantiated this process).
*/
listOrgs () { return Array.from(this._orgs.keys()) }
/**
* Names of every per-repo tracker this daemon knows about — live,
* persisted in the on-disk index, OR carried in via bootstrap keys
* (e.g. from a deployment bundle on a mirror peer).
*
* Used by the mirror-writer announce path: a mirror needs to enroll
* its localKeyHex on every shared tracker, but trackers are
* lazy-loaded so listRepos() is empty at boot. Iterating this set and
* calling getForRepo() opens each one so its localKeyHex is available.
*/
listAllKnownRepos () {
const names = new Set()
for (const n of this._repos.keys()) names.add(n)
for (const n of Object.keys(this._index.repos)) names.add(n)
for (const n of Object.keys(this._repoBootstrapKeys)) names.add(n)
return Array.from(names)
}
/** Same as listAllKnownRepos but for per-org trackers. */
listAllKnownOrgs () {
const names = new Set()
for (const n of this._orgs.keys()) names.add(n)
for (const n of Object.keys(this._index.orgs)) names.add(n)
for (const n of Object.keys(this._orgBootstrapKeys)) names.add(n)
return Array.from(names)
}
/**
* Bootstrap keys for every tracker this daemon has ever opened,
* grouped by scope. Used by dump-keys and device-invite bundles so
* peers join EVERY tracker — not just whatever's lazily loaded in
* this process's memory.
*
* Sourced from two places:
* 1. Persisted index on disk (populated by getForRepo/getForOrg)
* 2. In-memory cache (live trackers, authoritative if the index
* file is stale or missing)
*
* Also includes any bootstrapKeys passed in at construction — covers
* the joining-peer case where trackers haven't been opened yet but
* keys were carried in from the origin bundle.
*/
async keys () {
const repos = { ...this._index.repos, ...this._repoBootstrapKeys }
const orgs = { ...this._index.orgs, ...this._orgBootstrapKeys }
// Live trackers beat both — they reflect what's actually running
for (const [name, tracker] of this._repos) repos[name] = tracker.keyHex
for (const [name, tracker] of this._orgs) orgs[name] = tracker.keyHex
return { repos, orgs }
}
/**
* Aggregate counts across every active tracker.
*/
async counts () {
let total = 0
let open = 0
let closed = 0
for (const tracker of this._repos.values()) {
const c = await tracker.counts()
total += c.total || 0
open += c.open || 0
closed += c.closed || 0
}
for (const tracker of this._orgs.values()) {
const c = await tracker.counts()
total += c.total || 0
open += c.open || 0
closed += c.closed || 0
}
return { total, open, closed, repos: this._repos.size, orgs: this._orgs.size }
}
async close () {
for (const tracker of this._repos.values()) await tracker.close()
for (const tracker of this._orgs.values()) await tracker.close()
// Close forceResync side stores (their own Corestores) so their RocksDB
// locks release — a restart must be able to reopen them.
for (const store of this._sideStores.values()) { try { await store.close() } catch {} }
this._repos.clear()
this._orgs.clear()
this._sideStores.clear()
}
}