OverviewFilesCommitsIssuesDocsReleasesPeers

pearbrowser-desktop / scripts / autobee-catalog-smoke.js

182 lines · javascript ref:
// Live two-writer convergence smoke test for the Autobee collaborative
// catalog (Rollout Phase 1 — docs/AUTOBEE-RESEARCH.md). Exercises REAL
// autobase: two independent Corestores, a writer-invite handshake,
// concurrent edits, bidirectional replication, and convergence — plus a
// read-only (non-writer) instance.
//
// Run on demand (NOT part of `npm test`, since it spins up swarmless
// replication and can take a few seconds):
//
//   node scripts/autobee-catalog-smoke.js
//
// Exit 0 = converged + all assertions held; non-zero = failure.

import Corestore from 'corestore'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
// Manager is CommonJS (backend/, Bare-native); default-import + destructure.
import managerMod from '../backend/autobee-catalog-manager.cjs'
const { AutobeeCatalogManager } = managerMod

const tmps = []
async function tmpStore () {
  const dir = await mkdtemp(join(tmpdir(), 'autobee-smoke-'))
  tmps.push(dir)
  return new Corestore(dir)
}

// Pipe two corestores together so their cores replicate live.
function wire (a, b) {
  const s1 = a.replicate(true)
  const s2 = b.replicate(false)
  s1.on('error', () => {})
  s2.on('error', () => {})
  s1.pipe(s2).pipe(s1)
  return () => { try { s1.destroy() } catch {} try { s2.destroy() } catch {} }
}

const sleep = (ms) => new Promise((r) => setTimeout(r, ms))

// Poll until predicate() or timeout — autobase needs a few ticks to linearize
// across the replication stream.
async function until (label, predicate, ms = 8000) {
  const t0 = Date.now()
  while (Date.now() - t0 < ms) {
    if (await predicate()) return true
    await sleep(150)
  }
  throw new Error(`timeout waiting for: ${label}`)
}

function assert (cond, msg) { if (!cond) throw new Error('ASSERT FAILED: ' + msg) }

async function main () {
  console.log('🐝 Autobee collaborative-catalog smoke test\n')

  // --- Writer A boots a fresh catalog ------------------------------------
  const storeA = await tmpStore()
  // Explicit namespace so the create (bootstrap=null) and the later reopen
  // (bootstrap=A.key) address the SAME local view core. (Load-by-key in the
  // app defaults the namespace to the stable bootstrap key, so this only
  // matters for the create-then-reopen path the smoke exercises.)
  const A = await new AutobeeCatalogManager(storeA, { namespace: 'smoke' }).ready()
  console.log('  · A booted   autobase key:', A.key.slice(0, 16) + '…  writable:', A.writable)
  assert(A.writable, 'creator A must be writable')

  await A.rename('Shared Picks')
  await A.upsertApp({ id: 'keet', name: 'Keet', driveKey: 'a'.repeat(64) })

  // --- Writer B joins as a read-only replica first -----------------------
  const storeB = await tmpStore()
  const B = await new AutobeeCatalogManager(storeB, { bootstrap: A.key }).ready()
  const unwire = wire(storeA, storeB)
  console.log('  · B booted   local key:', B.localKey.slice(0, 16) + '…  writable:', B.writable)

  // B can read A's catalog even before being granted write.
  await until('B sees Keet (read-only replica)', async () => {
    const cat = (await B.catalog()).apps.map((a) => a.id)
    return cat.includes('keet')
  })
  const roView = await B.catalog()
  assert(roView.writable === false, 'B must report writable:false before invite')
  console.log('  · B (read-only) sees apps:', roView.apps.map((a) => a.id).join(', '), '| writable:', roView.writable)

  // --- A invites B as a writer -------------------------------------------
  await A.addWriter(B.localKey)
  await until('B becomes writable after invite', async () => {
    await B.update()
    return B.writable
  })
  console.log('  · B is now writable:', B.writable)

  // --- Concurrent edits from both writers --------------------------------
  await Promise.all([
    A.upsertApp({ id: 'pearpass', name: 'PearPass', driveKey: 'b'.repeat(64) }),
    B.upsertApp({ id: 'hiveworm', name: 'HiveWorm', driveKey: 'c'.repeat(64) })
  ])
  // B removes the app A added at boot; later linear order decides the winner.
  await B.removeApp('keet')

  // --- Converge ----------------------------------------------------------
  await until('A and B converge', async () => {
    await A.update(); await B.update()
    const a = (await A.catalog()).apps.map((x) => x.id).sort()
    const b = (await B.catalog()).apps.map((x) => x.id).sort()
    return a.length > 0 && JSON.stringify(a) === JSON.stringify(b)
  })

  const finalA = await A.catalog()
  const finalB = await B.catalog()
  const idsA = finalA.apps.map((x) => x.id).sort()
  const idsB = finalB.apps.map((x) => x.id).sort()
  console.log('\n  A view:', finalA.name, '→', idsA.join(', '))
  console.log('  B view:', finalB.name, '→', idsB.join(', '))

  assert(JSON.stringify(idsA) === JSON.stringify(idsB), 'A and B must converge to identical app sets')
  assert(finalA.name === finalB.name && finalA.name === 'Shared Picks', 'catalog name must converge')
  assert(idsA.includes('pearpass') && idsA.includes('hiveworm'), 'both concurrent upserts must survive')

  // --- Restart determinism: reopen A's store from disk -------------------
  // The manager now namespaces its Autobase substore, so A.close() frees only
  // that substore and leaves storeA open — close storeA before reopening a fresh
  // Corestore on the same dir (two Corestores on one dir would otherwise overlap).
  unwire()
  await A.close(); await storeA.close()
  const storeA2 = await reopen()
  const A2 = await new AutobeeCatalogManager(storeA2, { bootstrap: A.key, namespace: 'smoke' }).ready()
  const reopened = await A2.catalog()
  console.log('  · A reopened from disk →', reopened.apps.map((x) => x.id).sort().join(', '))
  assert(JSON.stringify(reopened.apps.map((x) => x.id).sort()) === JSON.stringify(idsA),
    'reopened view must match pre-restart view (op log rebuilds same view)')

  await B.close(); await storeB.close()
  await A2.close(); await storeA2.close()

  await createPatternScenario()

  console.log('\n✅ PASS — converged, read-only enforced, restart deterministic, owner-create verified')
}

// Reopen the same on-disk corestore (last temp dir A used).
async function reopen () {
  const dir = tmps[0]
  return new Corestore(dir)
}

// Mirrors catalog-manager.createAutobeeCatalog: mint the autobase key under a
// throwaway namespace, close, then reopen by key (default key-derived
// namespace) — it must still be writable — then rename + add an app. This is
// the exact owner-create dance the RPC layer performs.
async function createPatternScenario () {
  console.log('\n  — owner-create pattern (mint → reopen-by-key) —')
  const dir = await mkdtemp(join(tmpdir(), 'autobee-smoke-create-'))
  tmps.push(dir)

  // mint.close() now frees only the manager's namespaced substore (not the whole
  // Corestore), so close the mint store before reopening a fresh one on the dir.
  const mintStore = new Corestore(dir)
  const mint = await new AutobeeCatalogManager(mintStore, { bootstrap: null, namespace: 'mint' }).ready()
  const keyHex = mint.key
  await mint.close(); await mintStore.close()

  const ownerStore = new Corestore(dir)
  const owner = await new AutobeeCatalogManager(ownerStore, { bootstrap: keyHex }).ready()
  assert(owner.writable, 'owner reopened by key must be writable')
  await owner.rename('My Picks')
  await owner.upsertApp({ id: 'app1', name: 'App One', driveKey: 'd'.repeat(64) })
  const cat = await owner.catalog()
  console.log('  · created → name:', cat.name, '| apps:', cat.apps.map((a) => a.id).join(','), '| writable:', cat.writable)
  assert(cat.name === 'My Picks' && cat.apps.length === 1 && cat.writable,
    'created catalog must be writable with the rename + app applied')
  await owner.close(); await ownerStore.close()
}

main()
  .then(async () => { await cleanup(); process.exit(0) })
  .catch(async (err) => { console.error('\n❌ FAIL:', err && err.message); await cleanup(); process.exit(1) })

async function cleanup () {
  for (const dir of tmps) { try { await rm(dir, { recursive: true, force: true }) } catch {} }
}