OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / test / unit / app-registry-hyperbee.test.js

355 lines · javascript ref:
// app-registry-hyperbee: v0.8.25 regression tests for the Hyperbee-
// backed persistence layer in AppRegistry. Replaces the JSON-blob
// rewrite-on-every-mutation model with single-block bee.put per
// mutation. Same public API surface; persistence is internal.
//
// Tests:
//   1. Bee mode boots cleanly with no existing JSON file
//   2. Bee mode boots cleanly with existing JSON file → migrates to bee
//   3. set/update/delete/setAnchored persist + survive restart
//   4. Concurrent mutations don't corrupt the bee
//   5. Legacy JSON mode still works (no setStore call)

import test from 'brittle'
import { mkdtemp, rm, readFile, writeFile, access } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
// Production constructs corestores at state-bearing roots via the
// corestore-7 migration guard (storage-root-restore) — mirror that here or
// hypercore-storage's tmpFixStorage sweeps the pre-created JSON into db/.
import { openCorestore } from '../../packages/core/core/persistence/storage-root-restore.js'
import { AppRegistry } from 'p2p-hiverelay/core/app-registry.js'
import { admitPublicHiveAppEntry } from 'p2p-hiverelay/gateway/public-app-admission.js'

async function freshTmp () {
  return mkdtemp(join(tmpdir(), 'hiverelay-app-reg-bee-'))
}

async function exists (path) {
  try { await access(path); return true } catch { return false }
}

test('bee mode: empty start, set, restart preserves entries', async (t) => {
  const dir = await freshTmp()
  try {
    const appKey = 'a'.repeat(64)

    // Phase 1 — fresh registry, set one entry
    {
      const store = openCorestore(dir)
      await store.ready()
      const reg = new AppRegistry(dir, { store })
      await reg.load()
      reg.set(appKey, {
        type: 'app',
        appId: 'pearpaste',
        version: '0.1.0',
        publisherPubkey: 'b'.repeat(64),
        durability: 1,
        revocable: false
      })
      await reg.flush()
      await store.close()
    }

    // Phase 2 — fresh AppRegistry on same corestore, expect entry restored
    {
      const store = openCorestore(dir)
      await store.ready()
      const reg = new AppRegistry(dir, { store })
      const entries = await reg.load()
      t.is(reg.size, 1, 'one entry restored')
      t.is(reg.get(appKey).appId, 'pearpaste')
      t.is(reg.get(appKey).publisherPubkey, 'b'.repeat(64))
      t.is(reg.get(appKey).durability, 1)
      t.is(reg.get(appKey).revocable, false)
      t.is(entries.length, 1, 'load returns one reseed entry')
      await store.close()
    }
  } finally {
    await rm(dir, { recursive: true, force: true })
  }
})

test('bee mode: migrates existing JSON on first load', async (t) => {
  const dir = await freshTmp()
  try {
    const jsonPath = join(dir, 'app-registry.json')
    const bakPath = join(dir, 'app-registry.json.bak')

    // Pre-create a JSON file with two entries (legacy v0.8.24 format)
    await writeFile(jsonPath, JSON.stringify([
      {
        appKey: 'a'.repeat(64),
        appId: 'app-one',
        type: 'app',
        version: '1.0.0',
        blind: false,
        publisherPubkey: 'p'.repeat(64),
        durability: 0,
        revocable: true
      },
      {
        appKey: 'b'.repeat(64),
        appId: 'app-two',
        type: 'drive',
        version: '2.0.0',
        blind: true,
        retainUntil: Date.now() + 60_000
      }
    ], null, 2))

    const store = openCorestore(dir)
    await store.ready()
    const reg = new AppRegistry(dir, { store })

    let migrateEvent = null
    reg.on('migrated', (e) => { migrateEvent = e })

    const entries = await reg.load()

    t.ok(migrateEvent, 'migrated event fired')
    t.is(migrateEvent.count, 2, 'both entries migrated')
    t.is(migrateEvent.source, 'json')
    t.is(migrateEvent.target, 'hyperbee')

    t.is(reg.size, 2, 'both entries in memory')
    t.is(reg.get('a'.repeat(64)).appId, 'app-one')
    t.is(reg.get('b'.repeat(64)).appId, 'app-two')
    t.is(reg.get('b'.repeat(64)).blind, true)
    t.is(entries.length, 2, 'reseed list has both')

    const legacyPublic = reg.get('a'.repeat(64))
    t.is(legacyPublic.privacyTier, 'public', 'legacy hydration retains its compatibility default')
    t.is(legacyPublic.storageClass, 'persistent', 'legacy hydration retains its storage default')
    t.is(legacyPublic.availabilityClass, 'always-on', 'legacy hydration retains its availability default')
    t.alike(admitPublicHiveAppEntry(legacyPublic, {
      appKey: 'a'.repeat(64),
      publicAppKeys: []
    }), {
      allowed: false,
      reason: 'not-operator-approved'
    }, 'hydrated defaults cannot independently publish a legacy row')

    // JSON file renamed to .bak, bee now authoritative
    t.absent(await exists(jsonPath), 'app-registry.json renamed')
    t.ok(await exists(bakPath), 'app-registry.json.bak present')

    await store.close()
  } finally {
    await rm(dir, { recursive: true, force: true })
  }
})

test('bee mode: mutations persist across restart (set, setAnchored, delete)', async (t) => {
  const dir = await freshTmp()
  try {
    const keepKey = 'a'.repeat(64)
    const deleteKey = 'b'.repeat(64)

    // Phase 1
    {
      const store = openCorestore(dir)
      await store.ready()
      const reg = new AppRegistry(dir, { store })
      await reg.load()
      reg.set(keepKey, { type: 'app', appId: 'keep-me', maxStorage: 4096 })
      reg.set(deleteKey, { type: 'app', appId: 'delete-me' })
      reg.update(keepKey, {
        anchored: true,
        anchoredLength: 100,
        storageProvedDriveVersion: 100,
        storageProvedMetaLength: 1,
        storageProvedBlobLength: 0,
        storageProvedTotalBytes: 0,
        storageProvedMetaFork: 0,
        storageProvedBlobFork: 0
      })
      await reg.flush()
      reg.delete(deleteKey)
      await reg.flush()
      await store.close()
    }

    // Phase 2 — restart
    {
      const store = openCorestore(dir)
      await store.ready()
      const reg = new AppRegistry(dir, { store })
      await reg.load()
      t.is(reg.size, 1, 'only keep-me survived')
      t.ok(reg.has(keepKey))
      t.absent(reg.has(deleteKey), 'deleted entry gone after restart')
      const restored = reg.get(keepKey)
      t.is(restored.anchored, true, 'setAnchored persisted')
      t.is(restored.anchoredLength, 100, 'anchoredLength persisted')
      await store.close()
    }
  } finally {
    await rm(dir, { recursive: true, force: true })
  }
})

test('legacy JSON mode: still works when no store is passed', async (t) => {
  const dir = await freshTmp()
  try {
    const appKey = 'c'.repeat(64)

    // Phase 1 — no store, classic JSON path
    {
      const reg = new AppRegistry(dir)
      await reg.load()
      reg.set(appKey, { type: 'app', appId: 'legacy', version: '1.0.0' })
      await reg.flush()
    }

    // Phase 2 — read back via raw JSON to confirm legacy path persisted
    const jsonText = await readFile(join(dir, 'app-registry.json'), 'utf8')
    const parsed = JSON.parse(jsonText)
    t.is(parsed.length, 1)
    t.is(parsed[0].appId, 'legacy')

    // Phase 3 — new AppRegistry, no store, reads the same JSON file
    {
      const reg = new AppRegistry(dir)
      await reg.load()
      t.is(reg.size, 1)
      t.is(reg.get(appKey).appId, 'legacy')
      t.alike(admitPublicHiveAppEntry(reg.get(appKey), {
        appKey,
        publicAppKeys: []
      }), {
        allowed: false,
        reason: 'not-operator-approved'
      }, 'legacy JSON hydration remains private without local key approval')
    }
  } finally {
    await rm(dir, { recursive: true, force: true })
  }
})

test('bee mode: setStore after load() throws', async (t) => {
  const dir = await freshTmp()
  try {
    const store = openCorestore(dir)
    await store.ready()
    const reg = new AppRegistry(dir, { store })
    await reg.load()

    const store2 = openCorestore(dir + '-other')
    await store2.ready()
    t.exception(() => reg.setStore(store2), /setStore must be called before load/)

    await store.close()
    await store2.close()
    await rm(dir + '-other', { recursive: true, force: true })
  } finally {
    await rm(dir, { recursive: true, force: true })
  }
})

test('bee mode: concurrent set + delete of different keys both persist', async (t) => {
  const dir = await freshTmp()
  try {
    const keyA = 'a'.repeat(64)
    const keyB = 'b'.repeat(64)
    const keyC = 'c'.repeat(64)

    const store = openCorestore(dir)
    await store.ready()
    const reg = new AppRegistry(dir, { store })
    await reg.load()

    reg.set(keyA, { type: 'app', appId: 'a' })
    reg.set(keyB, { type: 'app', appId: 'b' })
    reg.set(keyC, { type: 'app', appId: 'c' })
    reg.delete(keyB)

    await reg.flush()
    await store.close()

    const store2 = openCorestore(dir)
    await store2.ready()
    const reg2 = new AppRegistry(dir, { store: store2 })
    await reg2.load()

    t.is(reg2.size, 2, 'A and C survived; B deleted')
    t.ok(reg2.has(keyA))
    t.absent(reg2.has(keyB), 'B deleted')
    t.ok(reg2.has(keyC))

    await store2.close()
  } finally {
    await rm(dir, { recursive: true, force: true })
  }
})

test('bee mode: low-debt tombstone re-add reuses history and survives restart', async (t) => {
  const dir = await freshTmp()
  const key = 'd'.repeat(64)
  try {
    let finalDebt = 0
    let finalBytes = 0
    {
      const store = openCorestore(dir)
      await store.ready()
      const reg = new AppRegistry(dir, { store })
      await reg.load()
      reg.set(key, { type: 'drive', appId: 'readd-ok', maxStorage: 4096 }, { persist: false })
      await reg.persistEntry(key, { throwOnError: true })
      const firstDebt = reg._metadataBudgets.get(key).bytes

      reg.delete(key, { persist: false })
      await reg.persistDelete(key, { throwOnError: true })
      const retiredDebt = reg._metadataBudgets.get(key).bytes
      t.ok(retiredDebt > firstDebt)

      reg.set(key, { type: 'drive', appId: 'readd-ok', version: '2.0.0', maxStorage: 4096 }, { persist: false })
      await reg.persistEntry(key, { throwOnError: true })
      finalDebt = reg._metadataBudgets.get(key).bytes
      finalBytes = reg._bee.core.byteLength
      t.ok(finalDebt > retiredDebt, 're-add adopts rather than resets tombstone debt')
      t.ok(finalDebt < 48 * 1024, 'normal low-debt re-add retains retirement reserve')
      await store.close()
    }

    {
      const store = openCorestore(dir)
      await store.ready()
      const reg = new AppRegistry(dir, { store })
      const entries = await reg.load()
      t.is(entries.length, 1)
      t.is(reg.get(key).version, '2.0.0')
      t.is(reg._metadataBudgets.get(key).bytes, finalDebt)
      t.is(reg._bee.core.byteLength, finalBytes)
      t.ok(reg._bee.core.byteLength <= reg._registryJournal.baselineBytes + 64 * 1024)
      await store.close()
    }
  } finally {
    await rm(dir, { recursive: true, force: true })
  }
})

test('bee mode: empty JSON file is still migrated cleanly', async (t) => {
  const dir = await freshTmp()
  try {
    const jsonPath = join(dir, 'app-registry.json')
    await writeFile(jsonPath, '[]') // empty array

    const store = openCorestore(dir)
    await store.ready()
    const reg = new AppRegistry(dir, { store })
    const entries = await reg.load()

    t.is(reg.size, 0)
    t.is(entries.length, 0)
    // Empty file should still be renamed to .bak so we don't try to migrate again
    t.absent(await exists(jsonPath))
    t.ok(await exists(jsonPath + '.bak'))

    await store.close()
  } finally {
    await rm(dir, { recursive: true, force: true })
  }
})