// repo-follower-mechanism.test.js — RepoFollower in-process proof.
//
// Verifies the core data flow without spinning up a daemon:
// 1. Follower opens source's RepoEventLog by key, replicates entries.
// 2. Follower opens source's HyperIssues by bootstrap key, sees issues
// in its FollowRegistry (per-source namespace).
// 3. Live entries for the followed repo name trigger sync.pull().
// 4. Live entries for OTHER repo names are ignored.
// 5. The follower never enrolls as a writer (tracker.writable === false).
//
// Mocks `sync` (we count pull invocations; no real git) and `swarm`
// (join returns an inert handle; corestores are wired via
// replicateAndSync instead).
import test from 'brittle'
import Corestore from 'corestore'
import { mkdtempSync } from '#node:fs'
import { tmpdir } from '#node:os'
import { join } from '#node:path'
import { replicateAndSync } from 'autobase-test-helpers'
import { HyperIssues } from 'hyper-issues'
import { RepoEventLog } from '../lib/services/repo-event-log.js'
import { FollowRegistry } from '../lib/follow-registry.js'
import { RepoFollower } from '../lib/services/repo-follower.js'
function makeStore () {
return new Corestore(mkdtempSync(join(tmpdir(), 'pgv3-rf-mech-')))
}
function makeMockSync () {
const pulls = []
return {
remotes: new Map(),
addRemote (name, srvKey) { this.remotes.set(name, srvKey) },
async pull (remoteName, repoName) {
pulls.push({ remoteName, repoName, at: Date.now() })
},
pulls
}
}
function makeMockSwarm () {
const joins = []
return {
joins,
join (topic, opts) {
joins.push({ topic, opts })
return {
async flushed () {},
async destroy () {}
}
}
}
}
async function waitFor (predicate, { ms = 5000, step = 50 } = {}) {
const deadline = Date.now() + ms
while (Date.now() < deadline) {
if (await predicate()) return true
await new Promise((r) => setTimeout(r, step))
}
return false
}
test('follower replicates source RepoEventLog + tracker, ignores other repos', async (t) => {
// ── SOURCE setup ──
const storeA = makeStore()
const sourceLog = new RepoEventLog({ corestore: storeA })
await sourceLog.ready()
// Source's tracker for 'foo'
const trackerA = new HyperIssues(storeA, { namespace: 'issues:repo:foo' })
await trackerA.ready()
const issueId = await trackerA.create({ title: 'pre-follow issue', author: 'alice' })
// Source appends event for 'foo' and an unrelated 'other'
await sourceLog.append({
op: 0, name: 'foo', head: 'aaa', ts: Date.now(),
issueBootstrapKey: trackerA.key
})
await sourceLog.append({
op: 0, name: 'other', head: 'bbb', ts: Date.now(),
issueBootstrapKey: null
})
// The "source server key" stand-in. In production it's the daemon's
// identity.publicKeyHex; here we use the log's discoveryKey hex
// (just needs to be a stable 64-hex per source).
const sourceSrvKey = sourceLog.core.discoveryKey.toString('hex')
// ── FOLLOWER setup ──
const storeB = makeStore()
const followReg = new FollowRegistry(storeB)
const sync = makeMockSync()
const swarm = makeMockSwarm()
const follower = new RepoFollower({
corestore: storeB,
swarm,
sync,
followRegistry: followReg,
sourceServerKey: sourceSrvKey,
repoName: 'foo',
issueBootstrapKey: trackerA.keyHex,
repoEventLogKey: sourceLog.core.key
})
await follower.start()
// The event log was opened; we need to feed bytes to it. RepoEventLog
// opens `corestore.get({ key })` — we manually wire the two stores
// via replicateAndSync. Use the autobase under the tracker as the
// sync target (the event log core piggybacks the same connection
// since we're sharing the corestore.replicate transport).
//
// For the event log + tracker autobase together, drive sync on:
// - tracker autobases (so tracker view replicates)
// - event-log cores (so event entries replicate)
// replicateAndSync handles autobases; for the bare event-log core
// we drive update() after pumping the autobase pipe.
const followerTracker = followReg.getOpenRepo(sourceSrvKey, 'foo')
t.ok(followerTracker, 'follower opened tracker')
// Pump replication for the tracker autobase first.
await replicateAndSync([trackerA.base, followerTracker.base])
// Now pump replication for the event log core via a direct stream.
const sa = storeA.replicate(true)
const sb = storeB.replicate(false)
sa.pipe(sb).pipe(sa)
await new Promise((r) => setTimeout(r, 100))
try { await follower._eventLog.core.update({ wait: true }) } catch {}
// ── ASSERTIONS ──
// (1) Follower sees source's issue.
const issue = await followerTracker.get(issueId)
t.ok(issue, 'follower sees source issue via tracker view')
t.is(issue.title, 'pre-follow issue')
// (2) Follower is NOT a writer of the source's tracker.
t.is(followerTracker.writable, false, 'follower tracker is read-only')
// (3) Wait for the live loop to consume both event log entries and
// trigger a pull for 'foo' (one entry matches, one doesn't).
await waitFor(() => sync.pulls.some((p) => p.repoName === 'foo'))
const fooPulls = sync.pulls.filter((p) => p.repoName === 'foo')
t.ok(fooPulls.length >= 1, 'pulled foo at least once (initial OR live-loop)')
const otherPulls = sync.pulls.filter((p) => p.repoName === 'other')
t.is(otherPulls.length, 0, 'did NOT pull other (filtered by name)')
// (4) addRemote happened under the follow:<srvKey> name, NOT 'primary'.
t.is(sync.remotes.size, 1)
t.is(sync.remotes.get('follow:' + sourceSrvKey.toLowerCase()), sourceSrvKey.toLowerCase())
t.absent(sync.remotes.has('primary'), 'follower does NOT reserve "primary"')
// (5) swarm.join called with client:true (read-only discovery).
t.is(swarm.joins.length, 1, 'one swarm.join')
t.is(swarm.joins[0].opts.server, false, 'server:false')
t.is(swarm.joins[0].opts.client, true, 'client:true')
// ── CLEANUP ──
sa.destroy(); sb.destroy()
await follower.close()
await followReg.close()
await trackerA.close()
await sourceLog.close()
await storeA.close()
await storeB.close()
})
test('live new issue on source replicates to follower', async (t) => {
const storeA = makeStore()
const sourceLog = new RepoEventLog({ corestore: storeA })
await sourceLog.ready()
const trackerA = new HyperIssues(storeA, { namespace: 'issues:repo:bar' })
await trackerA.ready()
const sourceSrvKey = sourceLog.core.discoveryKey.toString('hex')
const storeB = makeStore()
const followReg = new FollowRegistry(storeB)
const sync = makeMockSync()
const swarm = makeMockSwarm()
const follower = new RepoFollower({
corestore: storeB,
swarm,
sync,
followRegistry: followReg,
sourceServerKey: sourceSrvKey,
repoName: 'bar',
issueBootstrapKey: trackerA.keyHex,
repoEventLogKey: sourceLog.core.key
})
await follower.start()
// Open replication stream for the duration of the test.
const sa = storeA.replicate(true)
const sb = storeB.replicate(false)
sa.pipe(sb).pipe(sa)
await new Promise((r) => setTimeout(r, 100))
// Now create an issue AFTER follower started.
const id = await trackerA.create({ title: 'live issue', author: 'alice' })
const followerTracker = followReg.getOpenRepo(sourceSrvKey, 'bar')
t.ok(followerTracker)
// Wait for replication to land the new issue on the follower view.
const seen = await waitFor(async () => {
try {
await followerTracker.base.update()
const got = await followerTracker.get(id)
return !!got
} catch { return false }
})
t.ok(seen, 'follower observed live issue')
sa.destroy(); sb.destroy()
await follower.close()
await followReg.close()
await trackerA.close()
await sourceLog.close()
await storeA.close()
await storeB.close()
})
test('follower starts without event log key (no live loop, still pulls once)', async (t) => {
const storeA = makeStore()
const trackerA = new HyperIssues(storeA, { namespace: 'issues:repo:baz' })
await trackerA.ready()
const storeB = makeStore()
const followReg = new FollowRegistry(storeB)
const sync = makeMockSync()
const swarm = makeMockSwarm()
const follower = new RepoFollower({
corestore: storeB,
swarm,
sync,
followRegistry: followReg,
sourceServerKey: 'cafe' + 'd'.repeat(60),
repoName: 'baz',
issueBootstrapKey: trackerA.keyHex,
repoEventLogKey: null
})
await follower.start()
await waitFor(() => sync.pulls.length >= 1)
t.is(sync.pulls.length, 1, 'one initial pull only')
t.absent(follower._eventLog, 'no event log opened')
await follower.close()
await followReg.close()
await trackerA.close()
await storeA.close()
await storeB.close()
})