// Local search — over the records this node already holds. No index, no crawler, no relay.
//
// Two decisions worth stating because they are not the obvious ones:
//
// It searches the markdown SOURCE, not the stripped text. The source is the canonical bytes, so a
// substring match over it finds strictly more and never less: "world" still matches "**world**",
// and text inside a table or a link target stays findable. Searching a lossy projection would make
// findability depend on what the projection happened to drop.
//
// ⛔ It reports what it could NOT read. "No results" and "no results, and 12 records I hold
// pointers for but cannot fetch" are different answers, and a search box that renders them
// identically is the same silent-absence bug this codebase keeps refusing everywhere else.
import test from 'brittle'
import fs from '#node:fs'
import os from '#node:os'
import { join } from '#node:path'
import Corestore from 'corestore'
import { RecordStore } from '../lib/record-store.js'
import { Feed } from '../lib/feed-base.js'
import { Peer, POST, LIKE } from '../lib/peer.js'
import { attest } from './helpers.js'
const tmp = () => fs.mkdtempSync(join(os.tmpdir(), 'favela-search-'))
const rec = (body, createdAt = '2026-09-06T00:00:00Z') => ({ frontmatter: { createdAt }, body })
async function mkFeed () {
const records = tmp()
const f = new Feed(new Corestore(tmp()), null, { store: new RecordStore(records) })
await f.ready()
await f.addDevice(await attest(f))
await f.update()
f._recordsDir = records
return f
}
const mkPeer = async () => new Peer(await mkFeed()).ready()
test('finds a word across my feed and the feeds I hold', async (t) => {
const me = await mkPeer()
const them = await mkFeed()
t.teardown(() => Promise.all([me.close(), them.close()]))
await me.feed.put(POST, 'a1', rec('hyperbee is a btree'))
await them.put(POST, 'b1', rec('autobee is multi-writer'))
await me.follow(them.key, them)
await me.update()
await them.update()
const r = await me.search('bee')
t.is(r.results.length, 2, 'both feeds contribute')
t.is(r.unreadable, 0)
})
test('is case-insensitive and counts hits', async (t) => {
const me = await mkPeer()
t.teardown(() => me.close())
await me.feed.put(POST, 'a1', rec('Bee bee BEE elsewhere'))
await me.update()
const r = await me.search('bee')
t.is(r.results.length, 1)
t.is(r.results[0].hits, 3, 'every occurrence, regardless of case')
})
test('★ it searches the SOURCE, so markup never hides a word', async (t) => {
const me = await mkPeer()
t.teardown(() => me.close())
await me.feed.put(POST, 'a1', rec('hello **world** and [a link](https://example.com/world)'))
await me.update()
t.is((await me.search('world')).results[0].hits, 2, 'inside emphasis AND inside a link target')
})
test('scopes to a collection when asked', async (t) => {
const me = await mkPeer()
const them = await mkFeed()
t.teardown(() => Promise.all([me.close(), them.close()]))
await them.put(POST, 'b1', rec('findme in a post'))
await me.follow(them.key, them)
await me.update()
await them.update()
await me.like({ feed: them.key.toString('hex'), collection: POST, name: 'b1' })
await me.update()
t.is((await me.search('findme', { collection: POST })).results.length, 1)
t.is((await me.search('findme', { collection: LIKE })).results.length, 0, 'a like has no body to match')
})
test('no match is an empty result, not an error', async (t) => {
const me = await mkPeer()
t.teardown(() => me.close())
await me.feed.put(POST, 'a1', rec('nothing relevant'))
await me.update()
const r = await me.search('absent')
t.alike(r.results, [])
t.is(r.unreadable, 0)
})
test('⛔ records it cannot read are COUNTED, never silently skipped', async (t) => {
const me = await mkPeer()
t.teardown(() => me.close())
await me.feed.put(POST, 'a1', rec('findme here'))
await me.feed.put(POST, 'a2', rec('findme there'))
await me.update()
// Drop one record's BYTES while leaving its pointer converged — exactly the state a peer is in
// when it has replicated the log but not yet the file.
fs.unlinkSync(join(me.feed._recordsDir ?? me.feed.store._root, 'records', POST, 'a2.md'))
const r = await me.search('findme')
t.is(r.results.length, 1, 'only the readable one matched')
t.is(r.unreadable, 1, 'and the other is REPORTED — a gap you can name')
})
test('order is newest first and stable across runs', async (t) => {
const me = await mkPeer()
t.teardown(() => me.close())
await me.feed.put(POST, 'a', rec('findme old', '2026-09-01T00:00:00Z'))
await me.feed.put(POST, 'b', rec('findme new', '2026-09-05T00:00:00Z'))
await me.update()
const one = (await me.search('findme')).results.map((x) => x.name)
const two = (await me.search('findme')).results.map((x) => x.name)
t.alike(one, ['b', 'a'], 'newest first')
t.alike(one, two, 'and identical on a second run')
})
test('limit takes the newest N matches', async (t) => {
const me = await mkPeer()
t.teardown(() => me.close())
for (let i = 1; i <= 4; i++) {
await me.feed.put(POST, 'p' + i, rec('findme ' + i, '2026-09-0' + i + 'T00:00:00Z'))
}
await me.update()
t.is((await me.search('findme', { limit: 2 })).results.length, 2)
})
test('an empty query matches nothing rather than everything', async (t) => {
const me = await mkPeer()
t.teardown(() => me.close())
await me.feed.put(POST, 'a1', rec('anything'))
await me.update()
t.alike((await me.search('')).results, [], 'fail closed — an empty box is not "show me all"')
})