OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-proto-peer / test / inline-migration-boot.test.mjs

187 lines · javascript ref:
// Flipping a LEGACY DID to storeBackend:'overlay' must not wipe it (hyper-social#16 P1, e2jev).
//
// THE HAZARD, verified against source rather than assumed:
//   - a legacy DID holds full RECORDS in the autobase where {$oid,size} pointers belong, and NO record
//     files at all
//   - PointerSubstrate._resolve passes ptr.$oid (undefined) to store.ensureLocal, which falls back to the
//     local file; with no file it returns null and the key is DROPPED. The record set reads EMPTY.
//   - sign()'s anti-wipe guard does not catch it: `if (... records.length === 0 && this._recordStore)`
//     only aborts when `files.length > 0`, and here there are none
//   => the first sign() after the flip materializes an empty MST root and publishes a
//      delete-every-record commit.
//
// Test 1 is the CONTROL: it pins that empty read, so the fix is measured against the real failure rather
// than an assumption. Test 2 proves migrateInline repairs it from the INLINE COPY (fromInline), which is
// the only source of the bytes when no file exists. Test 3 pins the properties that make it safe to run as
// a boot step: idempotent, content-preserving, and refusing any key whose oid would change.
//
// Run: bare test/inline-migration-boot.test.mjs
import '../lib/bare-globals.js'
import test from 'brittle'
import fs from 'bare-fs'
import crypto from 'hypercore-crypto'
import { OverlayRepoStore } from 'atproto-store/lib/overlay-repo-store.js'
import { PointerSubstrate } from 'atproto-store/lib/pointer-substrate.js'

const POST = 'app.bsky.feed.post'
const PROFILE = 'app.bsky.actor.profile'
const tmp = () => { const d = '/tmp/hpp-inline-' + Math.floor(Math.random() * 1e9); fs.mkdirSync(d, { recursive: true }); return d }

// The StateBase, as the legacy substrate left it: plain values under record keys.
function memKV () {
  const m = new Map()
  return {
    _m: m,
    async set (k, v) { m.set(k, v) },
    async del (k) { m.delete(k) },
    async get (k) { return m.has(k) ? m.get(k) : null },
    async list (prefix = '') { const o = {}; for (const [k, v] of m) if (k.startsWith(prefix)) o[k] = v; return o },
    async listNow (prefix = '') { return this.list(prefix) },
    async getNow (k) { return this.get(k) },
    holdsSigner: () => false
  }
}

function mkOverlay (net) {
  return {
    async registerFile (overlayPath, diskPath) {
      const bytes = fs.readFileSync(diskPath)
      const oid = crypto.data(bytes).toString('hex')
      net.set(oid, bytes)
      return { contentHash: oid }
    },
    async fetchFile (oid, { destPath } = {}) {
      const bytes = net.get(oid)
      if (!bytes) return null
      fs.writeFileSync(destPath, bytes)
      return { path: destPath }
    },
    async hashFile (diskPath) {
      let bytes
      try { bytes = fs.readFileSync(diskPath) } catch { return null }
      return { contentHash: crypto.data(bytes).toString('hex'), size: bytes.length }
    },
    get peerCount () { return net.size ? 1 : 0 }
  }
}

// A DID exactly as e2jev sits today: inline records in the statebase, an overlay-backed store with NO files.
async function legacyShaped () {
  const net = new Map()
  const store = await new OverlayRepoStore(tmp(), { overlay: mkOverlay(net), did: 'did:plc:legacy' }).ready()
  const sb = memKV()
  const inline = {
    [POST + '/3aaa']: { $type: POST, text: 'one', createdAt: '2026-07-01T00:00:00.000Z' },
    [POST + '/3bbb']: { $type: POST, text: 'two', createdAt: '2026-07-01T00:00:01.000Z' },
    [PROFILE + '/self']: { $type: PROFILE, displayName: 'legacy' }
  }
  for (const [k, v] of Object.entries(inline)) await sb.set(k, v)
  await sb.set('__members__', [{ key: 'aa' }]) // a reserved key must never be treated as a record
  return { store, sb, net, inline, sub: new PointerSubstrate(sb, store) }
}

test('CONTROL: a legacy DID flipped to overlay reads its record set as EMPTY', async (t) => {
  const { sub, inline } = await legacyShaped()

  const resolved = await sub.list('')
  t.is(Object.keys(resolved).length, 0, 'every inline record is DROPPED — the set reads empty')
  t.is(Object.keys(inline).length, 3, 'even though the statebase holds 3 of them')

  // This is precisely what sign() would see, and with no record FILES the anti-wipe guard cannot fire —
  // so the first sign would publish a delete-every-record commit. That is the wipe.
})

test('migrateInline rebuilds the records FROM THE INLINE COPY, and the set survives', async (t) => {
  const { sub, store, inline } = await legacyShaped()

  const r = await sub.migrateInline()
  t.is(r.converted, 3, 'all three records converted')
  t.is(r.fromInline, 3, 'all three came FROM THE INLINE COPY — there were no files to read')
  t.is(r.failed, 0, 'none failed')
  t.is(r.mismatched, 0, 'none refused')
  t.is(r.total, 3, 'and the reserved __members__ key was not counted as a record')

  // The set now resolves — this is the difference between a signed repo and a wipe.
  const resolved = await sub.list('')
  t.is(Object.keys(resolved).length, 3, 'the record set is NON-EMPTY after migration')
  t.alike(resolved[POST + '/3aaa'], inline[POST + '/3aaa'], 'and the content is byte-for-byte the inline record')
  t.alike(resolved[PROFILE + '/self'], inline[PROFILE + '/self'], 'including the profile')

  // Every entry is now a real pointer, which is what makes it fetchable BY A PEER rather than only locally.
  const raw = await sub.statebase.list('')
  t.ok(raw[POST + '/3aaa'].$oid, 'the statebase entry is now a {$oid,size} pointer')
  t.ok(await store.get(POST, '3aaa'), 'and the bytes exist as a file the overlay can serve')
  t.alike(raw.__members__, [{ key: 'aa' }], 'the reserved key is untouched')
})

test('materialize-first: a failed publish pass leaves the repo PROTECTED, not wiped', async (t) => {
  // The ordering property. migrateInline writes every file BEFORE publishing any pointer, so if the
  // publishing half never lands — crash, lost writability, autobase error — the record FILES still exist.
  // That is what arms sign()'s anti-wipe guard (`records.length === 0 && files.length > 0`), which is
  // precisely the check that cannot fire for a legacy DID today because it has no files.
  const { sub, store } = await legacyShaped()

  // Make the publish half fail outright, leaving only pass 1's effects.
  sub.statebase.set = async () => { throw new Error('autobase unwritable') }

  const r = await sub.migrateInline()
  t.is(r.materialized, 3, 'pass 1 materialized every record to a file')
  t.is(r.converted, 0, 'pass 2 published nothing — every set threw')
  t.is(r.failed, 3, 'and the failures are reported, not swallowed')

  // The decisive assertion: the files the guard looks for now exist.
  const files = await store.records()
  t.is(files.length, 3, 'the record FILES are on disk, so the anti-wipe guard can fire')
  t.ok(await store.get(POST, '3aaa'), 'and each one is readable')

  // Interleaved put/set would have left the set half-materialized at the point of the first failure —
  // here the whole set is covered before anything is published.
})

test('anti-wipe guard, second arm: refuses to sign an empty read while INLINE records remain', async (t) => {
  // The fail-closed backstop. If the boot migration never ran (or threw before materializing anything)
  // there are no files, so the guard's original files check is blind — and the first sign would publish a
  // delete-every-record commit. The second arm reads the log directly: un-migrated inline records mean the
  // empty read is a migration artefact, never a genuine "all records deleted".
  const { SwarmDevice } = await import('../lib/swarm-device.js')
  const { sub, sb, store } = await legacyShaped()

  const device = new SwarmDevice(sb, { did: 'did:plc:legacy', privateKey: crypto.randomBytes(32), self: 'a', recordStore: store })
  device.repo.sb = sub
  device.head = { rev: '3aaa', commitCid: 'bafy' } // a PREVIOUSLY-SIGNED repo — the dangerous case
  sb.base = { writable: true }

  t.is(Object.keys(await sub.list('')).length, 0, 'precondition: the record set reads EMPTY')
  t.is((await store.records()).length, 0, 'and there are NO files, so the original arm cannot fire')

  const signed = await device.sign()
  t.is(signed, null, 'sign() REFUSED — no delete-every-record commit was published')

  // ...and the block is TEMPORARY: once the migration runs, the record set is no longer empty, so the
  // guard's own precondition stops holding and signing proceeds normally. A guard that latched would trade
  // a wipe for a permanently frozen repo.
  await sub.migrateInline()
  t.is(Object.keys(await sub.list('')).length, 3, 'after migration the set reads NON-empty')
  t.ok((await store.records()).length > 0, 'and the files exist, so neither arm has anything to fire on')
})

test('safe to run at boot: idempotent, and refuses to change published content', async (t) => {
  const { sub } = await legacyShaped()
  await sub.migrateInline()
  const after = await sub.statebase.list('')

  // Idempotent — a second pass converts nothing and rewrites nothing.
  const again = await sub.migrateInline()
  t.is(again.converted, 0, 'a second run converts nothing')
  t.is(again.alreadyPointer, 3, 'it sees three entries already in pointer shape')
  t.alike(await sub.statebase.list(''), after, 'and the statebase is byte-identical — no rewrite')

  // A tombstone is never resurrected into a record.
  const { sub: sub2 } = await legacyShaped()
  await sub2.statebase.set(POST + '/3aaa', { $deleted: true })
  const r2 = await sub2.migrateInline()
  t.is(r2.tombstones, 1, 'the tombstone is counted, not converted')
  const resolved2 = await sub2.list('')
  t.absent(resolved2[POST + '/3aaa'], 'and the deleted record stays deleted')
})