/** * Hyperswarm replication for the agent Corestore / HyperDB. * * Pattern (hyperdb-workshop bin.js): * swarm.on('connection', (conn) => store.replicate(conn)) * swarm.join(db.discoveryKey, { server: true, client: true }) * * Enable with PEARDATA_SWARM=1 (off by default — Phase B). * Requires HyperDB to be open first. */ import Hyperswarm from 'hyperswarm' import b4a from 'b4a' import { getCorestore, getDb } from './index.js' import { openRemoteDb } from './remote.js' import { getServerPublicKeyHex } from '../core/auth-keys.js' import logger from '../utils/logger.js' const log = logger.child('db:replicate') /** @type {import('hyperswarm')|null} */ let swarm = null export function isSwarmEnabled() { const v = process.env.PEARDATA_SWARM return v === '1' || v === 'true' || v === 'on' } /** * @returns {Promise} */ export async function startReplication() { if (!isSwarmEnabled()) { log.info('Swarm replication disabled (set PEARDATA_SWARM=1 to enable)') return null } const store = getCorestore() const model = getDb() if (!store || !model) { log.warn('Cannot start swarm — HyperDB not open') return null } if (swarm) return swarm swarm = new Hyperswarm({ keyPair: await store.createKeyPair('peardata-swarm'), }) swarm.on('connection', (conn, info) => { const peer = info?.publicKey ? b4a.toString(info.publicKey, 'hex').slice(0, 12) : '?' log.info('Swarm peer connected', { peer }) store.replicate(conn) conn.on('close', () => log.info('Swarm peer disconnected', { peer })) conn.on('error', (err) => log.warn('Swarm conn error', { peer, error: err.message })) }) const topic = model.discoveryKey swarm.join(topic, { server: true, client: true }) await swarm.flush() log.info('Swarm joined HyperDB topic', { discoveryKeyHex: model.discoveryKeyHex, dbKeyHex: model.publicKeyHex, }) await rejoinLinkedPeers().catch((err) => { log.warn('rejoinLinkedPeers failed', { error: err.message }) }) return swarm } /** * Join an additional remote DB discovery topic (pull mode for linked peers). * @param {string|Buffer} discoveryKeyHexOrBuf */ export async function joinRemoteTopic(discoveryKeyHexOrBuf) { if (!swarm) { throw new Error('Swarm not started — enable PEARDATA_SWARM=1') } const key = typeof discoveryKeyHexOrBuf === 'string' ? b4a.from(discoveryKeyHexOrBuf, 'hex') : discoveryKeyHexOrBuf swarm.join(key, { server: false, client: true }) log.info('Joined remote topic', { discoveryKeyHex: typeof discoveryKeyHexOrBuf === 'string' ? discoveryKeyHexOrBuf : b4a.toString(discoveryKeyHexOrBuf, 'hex'), }) } /** * Persist + open a linked peer for warm pull. * @param {{ discoveryKeyHex?: string|null, dbKeyHex?: string|null, syncMode?: string }} link */ export async function attachLinkedPeer(link) { const mode = link.syncMode || 'both' if (mode !== 'pull' && mode !== 'both') return { joined: false, opened: false } let joined = false if (isSwarmEnabled() && link.discoveryKeyHex) { try { await joinRemoteTopic(link.discoveryKeyHex) joined = true } catch (err) { log.warn('joinRemoteTopic failed', { error: err.message }) } } let opened = false if (link.dbKeyHex) { const remote = await openRemoteDb(link.dbKeyHex) opened = Boolean(remote) } return { joined, opened } } /** * Re-join swarm topics + open remote bees from persisted peer-links (boot). */ export async function rejoinLinkedPeers() { const db = getDb() if (!db) return { links: 0, joined: 0, opened: 0 } let localNodeId try { localNodeId = getServerPublicKeyHex().slice(0, 16) } catch { return { links: 0, joined: 0, opened: 0 } } const links = await db.listPeerLinks(localNodeId) let joined = 0 let opened = 0 for (const link of links) { const res = await attachLinkedPeer(link) if (res.joined) joined++ if (res.opened) opened++ } if (links.length) { log.info('Rejoined linked peers', { links: links.length, joined, opened }) } return { links: links.length, joined, opened } } export async function stopReplication() { if (!swarm) return try { await swarm.destroy() } catch { // ignore } swarm = null log.info('Swarm stopped') } export function getSwarm() { return swarm }