// follow-registry-isolation.test.js — proves the per-source namespace
// prevents collision between:
//
// 1. A daemon's OWN local tracker for repo `foo` (IssueRegistry)
// 2. Follower of `foo` from source S1 (FollowRegistry)
// 3. Follower of `foo` from source S2 (FollowRegistry)
//
// All three coexist in the same corestore. No apply-state crash.
// Each pair of (source, name) sees only its own data.
//
// This is THE load-bearing claim of follower mode — if this test fails,
// the whole design is wrong.
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 { FollowRegistry } from '../lib/follow-registry.js'
import { IssueRegistry } from '../lib/issue-registry.js'
function makeStore () {
return new Corestore(mkdtempSync(join(tmpdir(), 'pgv3-follow-iso-')))
}
test('one corestore hosts: local foo + follow foo@S1 + follow foo@S2', async (t) => {
// ── SOURCES (S1, S2) — each has its own HyperIssues named 'foo' ──
const storeS1 = makeStore()
const trackerS1 = new HyperIssues(storeS1, { namespace: 'issues:repo:foo' })
await trackerS1.ready()
await trackerS1.create({ title: 'from S1', author: 's1-author' })
const storeS2 = makeStore()
const trackerS2 = new HyperIssues(storeS2, { namespace: 'issues:repo:foo' })
await trackerS2.ready()
await trackerS2.create({ title: 'from S2', author: 's2-author' })
// ── DAEMON D — three trackers in one corestore ──
const storeD = makeStore()
// (1) D's own local 'foo' via IssueRegistry
const issueReg = new IssueRegistry(storeD)
const localFoo = await issueReg.getForRepo('foo')
await localFoo.create({ title: 'D local foo', author: 'd-author' })
t.ok(localFoo.keyHex, 'local foo opened')
// (2) D follows 'foo' from S1 via FollowRegistry
// (3) D follows 'foo' from S2 via FollowRegistry
const followReg = new FollowRegistry(storeD)
const followFooS1 = await followReg.openRepo(trackerS1.keyHex, 'foo', trackerS1.keyHex)
// ↑ second arg is repoName 'foo'; in production source-srv-hex !=
// tracker-key-hex, but for this test we use the tracker key as a
// stand-in for the source's serverKey just to get a unique 64-hex.
const followFooS2 = await followReg.openRepo(trackerS2.keyHex, 'foo', trackerS2.keyHex)
t.not(localFoo.keyHex, followFooS1.keyHex, 'local foo != follow S1 foo')
t.not(localFoo.keyHex, followFooS2.keyHex, 'local foo != follow S2 foo')
t.not(followFooS1.keyHex, followFooS2.keyHex, 'follow S1 != follow S2')
// ── REPLICATE ──
await replicateAndSync([trackerS1.base, followFooS1.base])
await replicateAndSync([trackerS2.base, followFooS2.base])
// ── ASSERT EACH FOLLOWER SEES ONLY ITS SOURCE'S DATA ──
const s1Issues = await followFooS1.list()
t.is(s1Issues.length, 1, 'S1 follower sees 1 issue')
t.is(s1Issues[0].title, 'from S1', 'S1 follower has S1 data')
const s2Issues = await followFooS2.list()
t.is(s2Issues.length, 1, 'S2 follower sees 1 issue')
t.is(s2Issues[0].title, 'from S2', 'S2 follower has S2 data')
// ── ASSERT D'S LOCAL FOO UNAFFECTED ──
const localIssues = await localFoo.list()
t.is(localIssues.length, 1, 'local foo has 1 issue')
t.is(localIssues[0].title, 'D local foo', 'local foo unchanged')
// ── ASSERT FOLLOWER IS READ-ONLY ──
// The follower opened with bootstrapKey = source.keyHex. Its
// base.local.key is auto-derived, but source never called addWriter
// for it, so the autobase will NOT mark it writable. Source remains
// writable as the original creator.
t.is(followFooS1.writable, false, 'S1 follower is not writable')
t.is(followFooS2.writable, false, 'S2 follower is not writable')
t.is(trackerS1.writable, true, 'S1 source remains writable')
t.is(trackerS2.writable, true, 'S2 source remains writable')
// listWriters reflects the explicit writer registry — no addWriter
// was ever called, so it's empty on both sides.
const s1Writers = await trackerS1.listWriters()
t.is(s1Writers.length, 0, 'S1 source has no explicitly-added writers')
const fwS1 = await followFooS1.listWriters()
t.is(fwS1.length, 0, 'S1 follower has no explicitly-added writers')
// ── CLEANUP ──
await followReg.close()
await issueReg.close()
await trackerS1.close()
await trackerS2.close()
await storeD.close()
await storeS1.close()
await storeS2.close()
})
test('repeat openRepo is idempotent (returns same instance)', async (t) => {
const storeS = makeStore()
const trackerS = new HyperIssues(storeS, { namespace: 'issues:repo:bar' })
await trackerS.ready()
const storeD = makeStore()
const reg = new FollowRegistry(storeD)
const a = await reg.openRepo(trackerS.keyHex, 'bar', trackerS.keyHex)
const b = await reg.openRepo(trackerS.keyHex, 'bar', trackerS.keyHex)
t.is(a, b, 'same instance returned')
await reg.close()
await trackerS.close()
await storeD.close()
await storeS.close()
})
test('close drops only the named tracker', async (t) => {
const storeS1 = makeStore()
const t1 = new HyperIssues(storeS1, { namespace: 'issues:repo:a' })
await t1.ready()
const storeS2 = makeStore()
const t2 = new HyperIssues(storeS2, { namespace: 'issues:repo:b' })
await t2.ready()
const storeD = makeStore()
const reg = new FollowRegistry(storeD)
await reg.openRepo(t1.keyHex, 'a', t1.keyHex)
await reg.openRepo(t2.keyHex, 'b', t2.keyHex)
t.is(reg.list().length, 2)
const closed = await reg.closeRepo(t1.keyHex, 'a')
t.is(closed, true)
t.is(reg.list().length, 1)
t.ok(reg.getOpenRepo(t2.keyHex, 'b'), 'other tracker still open')
await reg.close()
await t1.close()
await t2.close()
await storeD.close()
await storeS1.close()
await storeS2.close()
})
test('list() reflects open follows accurately', async (t) => {
const storeS = makeStore()
const ts = new HyperIssues(storeS, { namespace: 'issues:repo:x' })
await ts.ready()
const storeD = makeStore()
const reg = new FollowRegistry(storeD)
t.alike(reg.list(), [])
await reg.openRepo(ts.keyHex, 'x', ts.keyHex)
const l = reg.list()
t.is(l.length, 1)
t.is(l[0].scope, 'repo')
t.is(l[0].name, 'x')
t.is(l[0].sourceServerKey, ts.keyHex.toLowerCase())
t.is(l[0].keyHex, ts.keyHex)
await reg.close()
await ts.close()
await storeD.close()
await storeS.close()
})