OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-proto-peer / test / delete-tip-wedge.repro.test.js

106 lines · javascript ref:
// DELETE-THE-TIP WEDGE (repro + fix) — a SOLE writer that deletes its most-recent-committed record trips
// the forward-only sign gate forever: the record's CREATE is still in a #commit frame (netKeys has it),
// but the writer intentionally deleted it (view/curKeys lacks it), so the gate reads "view behind net head,
// missing <key>" and SKIPS every sign → no delete commit is ever produced → the chain wedges. Reproduced
// live on wj62/stortron with app.bsky.feed.post/healverify1 (docs/HANDOFF §28). The fix must let a
// LEGITIMATE delete through while still blocking a genuinely-behind view (test 2 of the sibling suite).
//
// Run: bare test/delete-tip-wedge.repro.test.js
import '../lib/bare-globals.js'
import test from 'brittle'
import b4a from 'b4a'
import { SwarmDevice } from '../lib/swarm-device.js'

const DID = 'did:plc:deletetip'
const KEY = (() => { const k = b4a.alloc(32); k[31] = 9; return k })()
const post = (text) => ({ $type: 'app.bsky.feed.post', text, createdAt: '2026-07-21T00:00:00.000Z' })

// The same in-memory KV the forward-only-sign suite uses (a converged, single-writer StateBase). del()
// hard-removes the key (matching StateBaseV3._applyDel), while the __commit__ frames persist.
class MemKV {
  constructor () { this.m = new Map() }
  async set (k, v) { this.m.set(k, v) }
  async get (k) { return this.m.has(k) ? this.m.get(k) : null }
  async getNow (k) { return this.get(k) }
  async del (k) { this.m.delete(k) }
  async list (prefix = '') { const o = {}; for (const [k, v] of this.m) if (k.startsWith(prefix)) o[k] = v; return o }
  async listNow (prefix = '') { return this.list(prefix) }
}

test('delete-the-tip: a sole writer signs the delete forward (does NOT wedge)', async (t) => {
  const sb = new MemKV()
  const A = new SwarmDevice(sb, { did: DID, privateKey: KEY, self: 'A', firehose: true })
  await A.putRecord('app.bsky.feed.post', 'r1', post('one'))
  await A.putRecord('app.bsky.feed.post', 'r2', post('two'))
  await A.putRecord('app.bsky.feed.post', 'healverify1', post('tip to delete'))
  const h1 = await A.sign({ force: true })
  t.ok(h1 && h1.rev, 'created 3 records + signed the network head (rev ' + (h1 && h1.rev) + ')')

  // Delete the MOST-RECENT-committed record (the tip). The view now lists {r1,r2}; the healverify1 CREATE
  // is still in the #commit frame at h1.rev, so netKeys still contains it.
  await A.deleteRecord('app.bsky.feed.post', 'healverify1')

  // The SAME sole writer signs again. This must produce a DELETE commit (rev > h1.rev), not skip forever.
  const h2 = await A.sign()
  t.ok(h2 && h2.rev, 'sign produced a forward commit for the delete (did NOT wedge on the forward-only gate)')
  t.ok(h2 && h2.rev > h1.rev, `delete commit rev advanced past the create (${h1 && h1.rev} -> ${h2 && h2.rev})`)

  // The record is gone from the view, and a re-sign is a clean no-op (chain settled, not re-wedged).
  const gone = (await A.repo.listRecords()).find((r) => r.rkey === 'healverify1')
  t.absent(gone, 'healverify1 is deleted from the repo view')
  const h3 = await A.sign()
  t.is(h3, null, 're-sign after the delete is a no-op (chain settled — not re-wedged, not a phantom commit)')
})

test('delete-the-tip AFTER RESTART: the delete still finalizes with a proper delete frame (stortron state)', async (t) => {
  const sb = new MemKV()
  const A = new SwarmDevice(sb, { did: DID, privateKey: KEY, self: 'A', firehose: true })
  await A.putRecord('app.bsky.feed.post', 'r1', post('one'))
  await A.putRecord('app.bsky.feed.post', 'r2', post('two'))
  await A.putRecord('app.bsky.feed.post', 'healverify1', post('tip'))
  const h1 = await A.sign({ force: true })
  await A.deleteRecord('app.bsky.feed.post', 'healverify1') // delete happens...

  // ...then the process RESTARTS before the delete is ever signed (exactly stortron: it restarted while
  // wedged). A fresh device shares the same converged StateBase; restore() re-seeds head + _lastRecords.
  const B = new SwarmDevice(sb, { did: DID, privateKey: KEY, self: 'B', firehose: true })
  const h2 = await B.sign()
  t.ok(h2 && h2.rev > h1.rev, `post-restart sign advanced past the wedge (${h1 && h1.rev} -> ${h2 && h2.rev})`)

  // The firehose MUST carry a delete op for healverify1 — else the relay never learns of the delete and a
  // later post's prev-link dangles. A 0-op "phantom" advance (no delete frame) would be a broken firehose.
  const frames = Object.values(await sb.listNow('__commit__/')).sort((a, b) => (a.rev < b.rev ? -1 : 1))
  const top = frames[frames.length - 1]
  const delOp = (top.ops || []).find((o) => o.action === 'delete' && o.path === 'app.bsky.feed.post/healverify1')
  t.ok(delOp, 'the newest #commit frame carries a delete op for healverify1 (relay learns of the delete)')
  t.ok(top.rev > h1.rev && top.prevData, 'the delete frame chains forward from the create head (prev-link intact)')
})

// A genuinely BEHIND view (pointer linearized, bytes not yet fetched) must STILL skip — the delete-fix must
// not weaken the anti-content-regression guard. Fake store: pointer present in the StateBase, ensureLocal
// returns null (bytes unreplicated), so the record is absent from the resolved view but PRESENT in rawKeys.
test('behind (pointer present, bytes unreplicated): forward-only gate STILL skips (no content regression)', async (t) => {
  const sb = new MemKV()
  const bytes = new Map() // oid -> record; ensureLocal misses for an unreplicated oid
  const store = {
    async put (collection, rkey, record) { const oid = collection + '/' + rkey; bytes.set(oid, record); return { oid, size: 1 } },
    async ensureLocal (collection, rkey, oid) { return bytes.has(oid) ? bytes.get(oid) : null }, // null = bytes not fetched
    async del (collection, rkey) { bytes.delete(collection + '/' + rkey) },
    async records () { return [] }
  }
  const A = new SwarmDevice(sb, { did: DID, privateKey: KEY, self: 'A', firehose: true, recordStore: store })
  await A.putRecord('app.bsky.feed.post', 'r1', post('one'))
  await A.putRecord('app.bsky.feed.post', 'r2', post('two'))
  const h1 = await A.sign({ force: true })

  // A third record's POINTER converges (writer added it) but its BYTES are not fetched here → the pointer is
  // in the StateBase (rawKeys) but the resolved view lists only {r1,r2}. Frame it into the net head.
  await sb.set('app.bsky.feed.post/r3', { $oid: 'app.bsky.feed.post/r3', size: 1 }) // pointer present, no bytes
  const real = Object.values(await sb.listNow('__commit__/')).pop()
  await sb.set('__commit__/' + (real.rev + 'z'), { ...real, rev: real.rev + 'z', ops: [{ action: 'create', path: 'app.bsky.feed.post/r3', cid: null, prev: null }], seq: (real.seq || 1) + 1 })

  const B = new SwarmDevice(sb, { did: DID, privateKey: KEY, self: 'B', firehose: true, recordStore: store })
  const hB = await B.sign()
  t.is(hB, null, 'pointer present + bytes unreplicated → gate SKIPS (would-be delete of a live r3 is blocked)')
})