// ★ The two-peer loop — hyper-social #61 §11.2 test 8.
//
// Everything before this was provable on one machine: apply against a Map, a feed against its own
// store, a timeline over feeds handed in by hand. This is the first test where a record has to
// LEAVE one peer and arrive at another, and it is the one the whole design is for.
//
// It runs over an in-memory duplex pair rather than a swarm. That is deliberate: the wiring under
// test is "does a feed replicate", and a swarm would add DHT timing and NAT traversal to a test
// that is not about either. The Hyperswarm wrapper on top is glue and is marked as untested.
import test from 'brittle'
import fs from '#node:fs'
import os from '#node:os'
import { join } from '#node:path'
import Corestore from 'corestore'
import { RecordStore } from '../lib/record-store.js'
import { Feed } from '../lib/feed-base.js'
import { Peer, POST } from '../lib/peer.js'
import { attest } from './helpers.js'
const tmp = () => fs.mkdtempSync(join(os.tmpdir(), 'favela-rep-'))
const rec = (body, createdAt = '2026-09-06T00:00:00Z') => ({ frontmatter: { createdAt }, body })
// Pipe two feeds together, as a transport would.
function pipe (a, b) {
const s1 = a.replicate(true)
const s2 = b.replicate(false)
s1.pipe(s2).pipe(s1)
return () => { s1.destroy(); s2.destroy() }
}
// Replication is asynchronous and there is no event that means "you now have everything" — so a
// test waits for the CONDITION it cares about rather than for a duration. A fixed sleep would pass
// on a fast machine and flake on a loaded one, which is how a suite starts lying.
async function until (fn, { timeout = 5000 } = {}) {
const deadline = Date.now() + timeout
while (Date.now() < deadline) {
const v = await fn()
if (v) return v
await new Promise((r) => setTimeout(r, 25))
}
return null
}
async function mkFeed (store = new Corestore(tmp()), key = null, records = tmp()) {
const f = new Feed(store, key, { store: new RecordStore(records) })
await f.ready()
return f
}
// ⚠ The records dir is SHARED between the two feeds in the tests that expect a readable record.
// That is standing in for hyper-overlay, which is not wired here: the log carries pointers and the
// bytes travel by a separate path, so a peer with the log and no byte path holds a pointer it cannot
// resolve — proven in the last test, and NOT a replication failure. Conflating the two is the
// mistake this comment exists to stop someone repeating; the first draft of this file made it.
test('★ a post authored on one peer is readable on another', async (t) => {
const records = tmp()
const aliceStore = new Corestore(tmp())
const alice = await mkFeed(aliceStore, null, records)
await alice.addDevice(await attest(alice))
await alice.update()
await alice.put(POST, 'p1', rec('crossed the wire'))
await alice.update()
// Bob opens ALICE'S feed by key — read-only, he is not a writer.
const bobStore = new Corestore(tmp())
const aliceOnBob = await mkFeed(bobStore, alice.key, records)
const unpipe = pipe(alice, aliceOnBob)
t.teardown(async () => { unpipe(); await alice.close(); await aliceOnBob.close() })
const got = await until(async () => {
await aliceOnBob.update()
return aliceOnBob.get(POST, 'p1')
})
t.ok(got, 'the record arrived')
t.is(got.body, 'crossed the wire\n', 'byte-identical body')
t.absent(aliceOnBob.writable, '⛔ and bob is NOT a writer on her feed')
t.alike(await aliceOnBob.devices(), await alice.devices(), 'he sees her roster, unchanged')
})
test('★ it lands in the follower\'s timeline', async (t) => {
const records = tmp()
const aliceStore = new Corestore(tmp())
const alice = await mkFeed(aliceStore, null, records)
await alice.addDevice(await attest(alice))
await alice.update()
await alice.put(POST, 'p1', rec('hello from alice', '2026-09-05T00:00:00Z'))
await alice.update()
// ⛔ TWO feeds on one Corestore need SEPARATE NAMESPACES. Without them the second Autobee derives
// the same local writer as the first and the pair deadlocks — the test does not fail, it hangs.
// This is how keet carries many rooms on one store, and it is not optional.
const bobStore = new Corestore(tmp())
const bobOwn = await mkFeed(bobStore.namespace('own'))
await bobOwn.addDevice(await attest(bobOwn))
await bobOwn.update()
await bobOwn.put(POST, 'mine', rec('hello from bob', '2026-09-01T00:00:00Z'))
await bobOwn.update()
const aliceOnBob = await mkFeed(bobStore.namespace('alice'), alice.key, records)
const unpipe = pipe(alice, aliceOnBob)
const bob = await new Peer(bobOwn).ready()
t.teardown(async () => { unpipe(); await alice.close(); await bob.close(); await aliceOnBob.close() })
await bob.follow(alice.key, aliceOnBob)
await until(async () => {
await bob.update()
return (await bob.timeline()).length === 2
})
t.alike((await bob.timeline()).map((x) => x.record.body),
['hello from alice\n', 'hello from bob\n'], 'merged, newest first, across the wire')
})
test('★ the bytes travel too — a large body arrives intact', async (t) => {
const aliceStore = new Corestore(tmp())
const records = tmp()
const alice = await mkFeed(aliceStore, null, records)
await alice.addDevice(await attest(alice))
await alice.update()
const body = 'x'.repeat(50000)
await alice.put(POST, 'p1', rec(body))
await alice.update()
// Bob's RecordStore points at the SAME directory, standing in for the overlay: the log carries
// pointers, and the bytes are fetched by oid out of band. Without an overlay wired, this is what
// "the bytes are available" looks like.
const bobStore = new Corestore(tmp())
const aliceOnBob = await mkFeed(bobStore, alice.key, records)
const unpipe = pipe(alice, aliceOnBob)
t.teardown(async () => { unpipe(); await alice.close(); await aliceOnBob.close() })
const got = await until(async () => {
await aliceOnBob.update()
return aliceOnBob.get(POST, 'p1')
})
t.is(got && got.body.length, 50001, 'intact')
})
test('⛔ a peer holding the log but NOT the bytes says so, rather than showing nothing', async (t) => {
const aliceStore = new Corestore(tmp())
const alice = await mkFeed(aliceStore)
await alice.addDevice(await attest(alice))
await alice.update()
await alice.put(POST, 'p1', rec('bytes alice has and bob does not'))
await alice.update()
// A DIFFERENT records dir and no overlay: bob converges the pointer and can never fetch the file.
const bobStore = new Corestore(tmp())
const aliceOnBob = await mkFeed(bobStore, alice.key, tmp())
const unpipe = pipe(alice, aliceOnBob)
t.teardown(async () => { unpipe(); await alice.close(); await aliceOnBob.close() })
await until(async () => {
await aliceOnBob.update()
return (await aliceOnBob.unresolved()).length === 1
})
t.is((await aliceOnBob.resolve(POST, 'p1')).status, 'unresolved',
'not "absent" and not "deleted" — the record exists and cannot be read')
t.alike(await aliceOnBob.list(), [], 'so it is not in the listing')
t.is((await aliceOnBob.unresolved()).length, 1, 'and it IS in unresolved() — a gap with a name')
})