#!/usr/bin/env node
/**
* test-poker-persistence-hypercore.js
*
* Validates HypercorePersistence end-to-end against a real Corestore:
*
* 1. createPersistentTable on a fresh tableKey: opens a core, creates the
* in-memory table, no entries to replay.
* 2. Submitting an entry mirrors to disk: core.length grows.
* 3. Restart cycle: stop adapter, close PokerApp, re-create both pointing
* at the same store + tableKey → replay restores the in-memory log
* and the writer's lastSeq cursor.
* 4. After restart, the writer can append seq N+1 (cursor was correctly
* restored from the replayed entries).
* 5. Corrupt block in the core → createPersistentTable throws and the
* half-created PokerApp table is cleaned up.
* 6. mirror-error event fires if the core's append fails (forced via a
* bad backing store), without rolling back the in-memory append.
* 7. listMirrors() returns the expected shape.
*/
import { mkdtemp, rm } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import Corestore from 'corestore'
import sodium from 'sodium-universal'
import b4a from 'b4a'
import { SignedLog } from '../packages/services/builtin/poker/signed-log.js'
import { PokerApp } from '../packages/services/builtin/poker/index.js'
import { HypercorePersistence } from '../packages/services/builtin/poker/persistence-hypercore.js'
let passed = 0
let failed = 0
function assert (condition, label) {
if (condition) { console.log(` PASS ${label}`); passed++ }
else { console.log(` FAIL ${label}`); failed++ }
}
function makeKeyPair () {
const publicKey = b4a.alloc(32)
const secretKey = b4a.alloc(64)
sodium.crypto_sign_keypair(publicKey, secretKey)
return { publicKey, secretKey, pubHex: b4a.toString(publicKey, 'hex') }
}
function signEntry (kp, entry) {
const canonical = SignedLog.canonicalBytes(entry)
const sig = b4a.alloc(64)
sodium.crypto_sign_detached(sig, canonical, kp.secretKey)
return { ...entry, signature: b4a.toString(sig, 'hex') }
}
const TABLE = 'a'.repeat(64)
async function setup () {
const dir = await mkdtemp(join(tmpdir(), 'poker-persist-'))
const store = new Corestore(dir)
await store.ready()
const app = new PokerApp()
await app.start({})
const persistence = new HypercorePersistence({ pokerApp: app, store })
return { dir, store, app, persistence }
}
async function teardown (env) {
try { await env.persistence.stop() } catch {}
try { await env.app.stop() } catch {}
try { await env.store.close() } catch {}
try { await rm(env.dir, { recursive: true, force: true }) } catch {}
}
// ── 1, 2. Create + mirror ──────────────────────────────────────────────────
async function testCreateAndMirror () {
console.log('\n── 1+2. Create + mirror to disk ──')
const env = await setup()
const a = makeKeyPair()
try {
const desc = await env.persistence.createPersistentTable({
tableKey: TABLE, writers: [a.pubHex]
})
assert(desc.tableKey === TABLE, 'descriptor returned')
assert(env.persistence.listMirrors().length === 1, 'one mirror active')
const now = Date.now()
const e0 = signEntry(a, { tableKey: TABLE, writer: a.pubHex, seq: 0, ts: now, payload: { kind: 'sit' } })
const r = env.app.submitEntry(TABLE, e0)
assert(r.ok, 'in-memory append ok')
// Mirror is async — give it a tick.
await new Promise(r => setTimeout(r, 50))
const mirrors = env.persistence.listMirrors()
assert(mirrors[0].length === 1, 'core.length = 1 after mirror')
} finally {
await teardown(env)
}
}
// ── 3, 4. Restart cycle ────────────────────────────────────────────────────
async function testRestart () {
console.log('\n── 3+4. Restart cycle replays from disk ──')
// First pass: write some entries.
const dir = await mkdtemp(join(tmpdir(), 'poker-restart-'))
const a = makeKeyPair()
const now = Date.now()
{
const store = new Corestore(dir)
await store.ready()
const app = new PokerApp()
await app.start({})
const p = new HypercorePersistence({ pokerApp: app, store })
await p.createPersistentTable({ tableKey: TABLE, writers: [a.pubHex] })
for (let i = 0; i < 3; i++) {
const e = signEntry(a, {
tableKey: TABLE, writer: a.pubHex, seq: i, ts: now, payload: { kind: 'act', i }
})
assert(env.appAppend(app, e), 'first-pass append ' + i)
}
await new Promise(r => setTimeout(r, 100))
await p.stop()
await app.stop()
await store.close()
}
// Second pass: open same store, expect rehydration.
{
const store = new Corestore(dir)
await store.ready()
const app = new PokerApp()
await app.start({})
const p = new HypercorePersistence({ pokerApp: app, store })
let hydratedCount = -1
p.on('hydrated', (h) => { hydratedCount = h.count })
await p.createPersistentTable({ tableKey: TABLE, writers: [a.pubHex] })
assert(hydratedCount === 3, 'hydrated 3 entries (' + hydratedCount + ')')
const log = app.getLog(TABLE, 0)
assert(log.entries.length === 3, 'in-memory log has 3 entries after replay')
assert(log.entries[2].payload.i === 2, 'last entry payload correct')
// Cursor restored: writer should be able to append seq 3.
const nextSeq = signEntry(a, {
tableKey: TABLE, writer: a.pubHex, seq: 3, ts: now, payload: { kind: 'after-restart' }
})
const r = app.submitEntry(TABLE, nextSeq)
assert(r.ok, 'post-restart append at seq 3 succeeded (' + (r.reason || 'ok') + ')')
await p.stop()
await app.stop()
await store.close()
}
await rm(dir, { recursive: true, force: true })
}
// Small helper used inside testRestart's first pass — wraps submitEntry +
// asserts ok in one line.
const env = {
appAppend (app, entry) {
const r = app.submitEntry(TABLE, entry)
return r.ok
}
}
// ── 5. Corrupt block → throws, cleans up ───────────────────────────────────
async function testCorruptBlock () {
console.log('\n── 5. Corrupt block on replay ──')
const dir = await mkdtemp(join(tmpdir(), 'poker-corrupt-'))
const a = makeKeyPair()
// Write a single garbage block directly to the core.
{
const store = new Corestore(dir)
await store.ready()
const core = store.get({ name: 'poker/' + TABLE })
await core.ready()
await core.append(Buffer.from('not-json', 'utf8'))
await store.close()
}
// Open and try to provision — should throw and not leave a half-table.
{
const store = new Corestore(dir)
await store.ready()
const app = new PokerApp()
await app.start({})
const p = new HypercorePersistence({ pokerApp: app, store })
let threw = false
try { await p.createPersistentTable({ tableKey: TABLE, writers: [a.pubHex] }) }
catch { threw = true }
assert(threw, 'createPersistentTable throws on corrupt block')
assert(app.listTables().length === 0, 'no half-table left behind')
await p.stop()
await app.stop()
await store.close()
}
await rm(dir, { recursive: true, force: true })
}
// ── 6. mirror-error event ─────────────────────────────────────────────────
async function testMirrorErrorEvent () {
console.log('\n── 6. mirror-error event on append failure ──')
const env = await setup()
const a = makeKeyPair()
try {
await env.persistence.createPersistentTable({ tableKey: TABLE, writers: [a.pubHex] })
// Sabotage the core's append by replacing it with a thrower.
const internal = env.persistence._mirrors.get(TABLE)
internal.core.append = () => { return Promise.reject(new Error('forced-failure')) }
let errored = null
env.persistence.on('mirror-error', (e) => { errored = e })
const now = Date.now()
const e0 = signEntry(a, { tableKey: TABLE, writer: a.pubHex, seq: 0, ts: now, payload: 'x' })
const r = env.app.submitEntry(TABLE, e0)
assert(r.ok, 'in-memory append still succeeds even if mirror will fail')
// Wait briefly for the async mirror to fail.
await new Promise(r => setTimeout(r, 50))
assert(errored && errored.error === 'forced-failure',
'mirror-error fired with the underlying error (' + (errored ? errored.error : 'null') + ')')
} finally {
await teardown(env)
}
}
// ── 7. listMirrors shape ───────────────────────────────────────────────────
async function testListMirrorsShape () {
console.log('\n── 7. listMirrors shape ──')
const env = await setup()
const a = makeKeyPair()
try {
await env.persistence.createPersistentTable({ tableKey: TABLE, writers: [a.pubHex] })
const list = env.persistence.listMirrors()
assert(list.length === 1, 'one mirror')
assert(typeof list[0].tableKey === 'string' && list[0].tableKey === TABLE, 'tableKey present')
assert(typeof list[0].coreKey === 'string' && /^[0-9a-f]{64}$/.test(list[0].coreKey),
'coreKey is hex64')
assert(typeof list[0].length === 'number', 'length is a number')
} finally {
await teardown(env)
}
}
// ── 8. Valid JSON but bad SHAPE → caught before _replay ───────────────────
async function testBadShapeBlock () {
console.log('\n── 8. Valid JSON but bad shape on replay ──')
const dir = await mkdtemp(join(tmpdir(), 'poker-badshape-'))
const a = makeKeyPair()
// Write a JSON-valid block that's missing `writer` — would crash inside
// SignedLog._replay with TypeError if persistence didn't catch it.
{
const store = new Corestore(dir)
await store.ready()
const core = store.get({ name: 'poker/' + TABLE })
await core.ready()
await core.append(Buffer.from(JSON.stringify({
tableKey: TABLE, seq: 0, ts: Date.now(), signature: 'aa', payload: 'x'
// intentionally no `writer`
}), 'utf8'))
await store.close()
}
{
const store = new Corestore(dir)
await store.ready()
const app = new PokerApp()
await app.start({})
const p = new HypercorePersistence({ pokerApp: app, store })
let err = null
try { await p.createPersistentTable({ tableKey: TABLE, writers: [a.pubHex] }) }
catch (e) { err = e }
assert(err && /bad-shape/.test(err.message), 'bad-shape block triggers specific error (' + (err ? err.message : 'no throw') + ')')
assert(err && /writer/.test(err.message), 'error names the missing field')
assert(app.listTables().length === 0, 'no half-table left behind')
await p.stop(); await app.stop(); await store.close()
}
await rm(dir, { recursive: true, force: true })
}
async function main () {
await testCreateAndMirror()
await testRestart()
await testCorruptBlock()
await testMirrorErrorEvent()
await testListMirrorsShape()
await testBadShapeBlock()
console.log(`\n── done: ${passed} passed, ${failed} failed ──`)
process.exit(failed === 0 ? 0 : 1)
}
main().catch((err) => {
console.error('fatal:', err)
process.exit(2)
})