/**
* seedFilePathsFromIndex — rebuilding the in-memory serve maps at boot.
*
* The maps (`_filePaths`, `_contentHashPaths`) are in-memory and only
* registerFile() fills them, so without this seed a restarted node serves only
* what it wrote since boot. 0.3.9 added the seed. This file covers the seed
* itself, which had NO tests — which is how the bug below survived three
* production sightings (2026-08-08 136 MiB APK, 2026-08-25 471 MB zip,
* 2026-08-29 168 MB APK: bytes on disk, hash advertised, peer told "no holder").
*
* THE BUG: FileIndex.putFile persists `contentHash: meta.contentHash || null`
* with a `hashing` flag, so a null hash is a first-class stored state. The seed
* set the PATH map unconditionally but the HASH map only `if (f.contentHash)`,
* while counting the entry as seeded either way. Such an entry is therefore
* invisible to fetch-by-oid, reports as healthy, and — because consumers build
* their "already known" set from what the resolver saw — is skipped by the
* repair pass written for exactly this failure.
*
* FIX CONTRACT (deliberately non-breaking): the return stays a NUMBER, but now
* counts only entries actually seeded into the HASH map — i.e. genuinely
* servable by oid. atproto-store logs it as "servable-by-oid after restart",
* a claim that was false before this change and is true after it, with no edit
* there. The resolver additionally receives the index ENTRY, so a consumer can
* tell a fully-seeded entry from a half-seeded one and route the latter to its
* repair pass instead of skipping it.
*/
import test from 'brittle'
import crypto from 'hypercore-crypto'
import { tmpStore, tmpDir, fs, path } from './helpers.js'
import { HyperOverlay } from '../lib/overlay.js'
async function makeOverlay (label) {
const ov = new HyperOverlay(tmpStore('seed-' + label), {
namespace: 'overlay-v2',
destDir: tmpDir('seed-' + label + '-dest')
})
await ov.ready()
return ov
}
test('seedFilePathsFromIndex: a hashed entry seeds BOTH maps', async (t) => {
const ov = await makeOverlay('hashed')
t.teardown(async () => { try { await ov.close() } catch {} })
const dir = tmpDir('seed-hashed-files')
const body = crypto.randomBytes(4096)
const disk = path.join(dir, 'a.bin')
fs.writeFileSync(disk, body)
const hash = crypto.data(body).toString('hex')
await ov._index.putFile('release:a.bin', { contentHash: hash, size: body.length, mtime: Date.now() })
const res = await ov.seedFilePathsFromIndex(() => disk)
t.is(res, 1, 'one entry seeded (return stays a NUMBER — atproto-store reads it as one)')
t.is(ov._filePaths.get('release:a.bin'), disk, 'path map seeded')
t.is(ov._contentHashPaths.get(hash), disk, 'HASH map seeded — servable by oid')
})
test('seedFilePathsFromIndex: a NULL-contentHash entry is reported, not silently half-seeded', async (t) => {
// The exact production shape: the file is on disk and correct, but its
// overlay index entry was written while still `hashing`, so the hash is null.
const ov = await makeOverlay('nullhash')
t.teardown(async () => { try { await ov.close() } catch {} })
const dir = tmpDir('seed-nullhash-files')
const body = crypto.randomBytes(4096)
const disk = path.join(dir, 'big.apk')
fs.writeFileSync(disk, body)
await ov._index.putFile('release:big.apk', { contentHash: null, size: body.length, mtime: Date.now(), hashing: true })
const seen = []
const res = await ov.seedFilePathsFromIndex((overlayPath, entry) => {
// The consumer must be able to tell a fully-seeded entry from a half-seeded
// one — it builds its "already known, skip repair" set from this callback.
seen.push({ overlayPath, contentHash: entry ? entry.contentHash : undefined })
return disk
})
t.is(seen.length, 1, 'resolver called once')
t.is(seen[0].contentHash, null,
'resolver RECEIVES the entry, so a null hash is visible to the consumer')
t.is(res, 0, 'NOT counted as seeded — the count now means "servable by oid"')
t.is(ov._filePaths.get('release:big.apk'), disk, 'path map still seeded')
t.is(ov._contentHashPaths.size, 0, 'hash map correctly empty — nothing to serve by oid')
})
test('seedFilePathsFromIndex: counts split so a healthy log cannot hide a broken entry', async (t) => {
const ov = await makeOverlay('mixed')
t.teardown(async () => { try { await ov.close() } catch {} })
const dir = tmpDir('seed-mixed-files')
const good = crypto.randomBytes(1024)
const goodPath = path.join(dir, 'good.bin')
fs.writeFileSync(goodPath, good)
const goodHash = crypto.data(good).toString('hex')
const badPath = path.join(dir, 'bad.bin')
fs.writeFileSync(badPath, crypto.randomBytes(1024))
await ov._index.putFile('release:good.bin', { contentHash: goodHash, size: good.length, mtime: Date.now() })
await ov._index.putFile('release:bad.bin', { contentHash: null, size: 1024, mtime: Date.now(), hashing: true })
const res = await ov.seedFilePathsFromIndex((p) => path.join(dir, p.slice('release:'.length)))
t.is(res, 1, 'only the servable one counts — a healthy total cannot hide a broken entry')
t.is(ov._contentHashPaths.get(goodHash), goodPath, 'good file servable by oid')
})
test('seedFilePathsFromIndex: an unresolvable path is skipped entirely', async (t) => {
const ov = await makeOverlay('unresolved')
t.teardown(async () => { try { await ov.close() } catch {} })
await ov._index.putFile('other:x.bin', { contentHash: 'ff'.repeat(32), size: 1, mtime: Date.now() })
// A consumer that does not own this namespace returns null for it.
const res = await ov.seedFilePathsFromIndex(() => null)
t.is(res, 0, 'nothing seeded')
t.is(ov._filePaths.size, 0)
})