// ⛔ A restarted node must still SERVE what it wrote before the restart.
//
// hyper-overlay resolves an incoming fetch-by-oid through IN-MEMORY maps that only `registerFile()`
// fills. Nothing rebuilds them at boot, so a node serves only what it registered since it started —
// every other file sits on disk, indexed, with a matching hash, and unreachable. That cost 75+
// silent hours on wj62; 0.3.9 added `seedFilePathsFromIndex(resolve)` to fix it, and the CONSUMER
// has to call it. favela-peer never did.
//
// MEASURED 2026-09-07, live: tabertron held 9 pointers from ashatron and could read 0 of them,
// stable across four attempts, while ashatron read its own posts fine off local disk — because
// ashatron's daemon had been restarted minutes earlier.
//
// ⛔ And the seed could not have worked as the paths stood. A record's overlay path was
// `/records/<collection>/<name>.md` with NO feed in it, while every feed has its own directory —
// so the path could not be resolved back to a file, and two feeds holding `post/p1` registered the
// SAME overlay path, the second overwriting the first.
import test from 'brittle'
import fs from '#node:fs'
import os from '#node:os'
import { join } from '#node:path'
import { RecordStore } from '../lib/record-store.js'
import { FavelaNode } from '../lib/node.js'
import { saveCredential } from '../lib/device.js'
import { identity } from './helpers.js'
const tmp = () => fs.mkdtempSync(join(os.tmpdir(), 'favela-seed-'))
test('★ a record\'s overlay path names its FEED, so it resolves back to a file', async (t) => {
const storage = tmp()
const mine = new RecordStore(join(storage, 'records', 'self'), { base: storage })
const theirs = new RecordStore(join(storage, 'records', 'ab'.repeat(32)), { base: storage })
const a = mine._overlayPath('post', 'p1')
const b = theirs._overlayPath('post', 'p1')
t.not(a, b, 'two feeds holding the same record name are two different files')
// ⛔ The property the seed needs: overlay path joined onto the storage root IS the disk path.
// Without it `seedFilePathsFromIndex` has nothing to resolve to and the map stays empty.
t.is(join(storage, a), mine._pathFor('post', 'p1'))
t.is(join(storage, b), theirs._pathFor('post', 'p1'))
})
test('★★ a node SEEDS the serve map at boot, from what it already indexed', async (t) => {
const storage = tmp()
const id = await identity()
saveCredential(storage, await id.device())
const seen = { calls: 0, resolved: [] }
// A recording overlay: enough surface for boot, and it captures the resolver so the test can ask
// what it would map — which is the thing that was broken, not the fact of calling.
const overlay = {
async ready () {},
async seedFilePathsFromIndex (resolve) {
seen.calls++
seen.resolve = resolve
return 0
},
attachProtocol () {},
async registerFile () { return { oid: 'ab'.repeat(32), size: 1 } },
hashFile: async () => null,
async fetchFile () { return null },
async unregisterFile () {},
async close () {}
}
const n = new FavelaNode(storage, { overlay, transport: { async join () {}, async leave () {}, async destroy () {} } })
await n.ready()
t.teardown(() => n.close())
t.is(seen.calls, 1, 'seeded once, at boot — not per feed and not never')
t.is(typeof seen.resolve, 'function')
// Every kind of file this node registers must resolve, or that kind stays unservable.
// ⚠ They have to EXIST — the resolver refuses to point the serve map at a file we do not hold.
const files = [
['records/self/records/post', 'p1.md', '/records/self/records/post/p1.md'],
['blobs', 'cd'.repeat(32), '/blobs/' + 'cd'.repeat(32)],
['avatars', 'ef'.repeat(32), '/avatars/' + 'ef'.repeat(32)]
]
for (const [dir, name, overlayPath] of files) {
fs.mkdirSync(join(storage, dir), { recursive: true })
fs.writeFileSync(join(storage, dir, name), 'bytes')
t.is(seen.resolve(overlayPath), join(storage, dir, name), overlayPath)
}
})
test('⛔ the resolver refuses anything outside the storage root', async (t) => {
const storage = tmp()
const id = await identity()
saveCredential(storage, await id.device())
let resolve
const overlay = {
async ready () {}, attachProtocol () {},
async seedFilePathsFromIndex (r) { resolve = r; return 0 },
async registerFile () { return {} }, hashFile: async () => null,
async fetchFile () { return null }, async unregisterFile () {}, async close () {}
}
const n = new FavelaNode(storage, { overlay, transport: { async join () {}, async leave () {}, async destroy () {} } })
await n.ready()
t.teardown(() => n.close())
// The index is data on disk. An entry with a traversing path must not be able to point the serve
// map at a file outside this node's storage — the map is what gets HANDED to peers.
t.is(resolve('/records/../../../etc/passwd'), null)
// ⚠ And an entry pointing at a file that is not there — which every entry written under the OLD
// overlay-path shape now is. Seeding those would offer peers a file we do not have.
t.is(resolve('/records/self/records/post/never-written.md'), null)
t.is(resolve('/../etc/passwd'), null)
t.is(resolve('not-absolute'), null)
})
test('★★ the repair pass re-registers files the seed could not resolve', async (t) => {
const storage = tmp()
const registered = []
const overlay = {
async registerFile (overlayPath, disk, meta) { registered.push([overlayPath, disk, meta.size]); return { oid: 'ab'.repeat(32), size: meta.size } },
hashFile: async () => null, async fetchFile () { return null }, async unregisterFile () {}
}
const store = new RecordStore(join(storage, 'records', 'self'), { overlay, base: storage })
await store.put('post', 'p1', { frontmatter: {}, body: 'one' })
await store.put('post', 'p2', { frontmatter: {}, body: 'two' })
registered.length = 0
// ⛔ Nothing else rewrites these. `put()` registers only what it writes, and the seed skips index
// entries whose path resolves to no file — which is every entry from the older path shape. Without
// this pass those records stay unservable for ever while sitting on disk.
const n = await store.reregisterLocal()
t.is(n, 2, 'both records re-registered')
t.alike(registered.map((r) => r[0]).sort(),
['/records/self/records/post/p1.md', '/records/self/records/post/p2.md'],
'under the resolvable path shape')
t.ok(registered.every((r) => r[2] > 0), 'with a real size, read from disk')
})
test('⚠ the repair survives an unreadable file rather than stopping at it', async (t) => {
const storage = tmp()
const overlay = {
async registerFile (p) { if (p.includes('bad')) throw new Error('nope'); return { oid: 'ab'.repeat(32), size: 1 } },
hashFile: async () => null, async fetchFile () { return null }, async unregisterFile () {}
}
const store = new RecordStore(join(storage, 'records', 'self'), { overlay, base: storage })
await store.put('post', 'good1', { frontmatter: {}, body: 'a' })
await store.put('post', 'bad', { frontmatter: {}, body: 'b' })
await store.put('post', 'good2', { frontmatter: {}, body: 'c' })
t.is(await store.reregisterLocal(), 2, 'one failure does not cost the others')
})