// 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')
})