// The bsky projection, running for as long as the desktop app runs.
//
// ⛔ WHY. favela-host only projected on demand, as a dry run by default, so a desktop that ran it instead
// of the four separate apps would silently stop posting to bsky — favela-bridge's `--watch` daemon was
// the only thing sending (hyper-social #77 §5, change 3). This replaces that daemon inside the app.
// ⛔ EVENT-DRIVEN, NOT POLLED: one pass at boot, then one per change to the node's own feed — the
// standing rule is "rip out polling timers". Coalesced: never two passes at once, and a change that
// lands during a pass gets exactly one more.
import test from 'brittle'
import { EventEmitter } from 'bare-events'
import { watchProjection } from '../lib/project.js'
const tick = () => new Promise((resolve) => setTimeout(resolve, 20))
// A fake feed whose single writer core emits 'append' — the event a real post produces.
function fakeNode () {
const core = new EventEmitter()
const base = { writers: [{ core }], emit: (ev) => { if (ev === 'update') core.emit('append') } }
return { node: { peer: { feed: { base } } }, base }
}
test('no identity, no projection', async (t) => {
const { node, base } = fakeNode()
let runs = 0
const w = await watchProjection(node, Promise.resolve(null), { project: async () => { runs++ } })
base.emit('update'); await tick()
t.is(runs, 0)
t.absent(w.watching)
})
test('one pass at boot, then one per change to the own feed, always sending', async (t) => {
const { node, base } = fakeNode()
const calls = []
const w = await watchProjection(node, Promise.resolve({ nodes: [{}] }), { project: async (n, hpp, opts) => { calls.push(opts.send) } })
t.teardown(() => w.stop())
await tick()
t.is(calls.length, 1, 'the boot pass')
base.emit('update'); await tick()
t.is(calls.length, 2, 'a change triggers a pass')
t.ok(calls.every((s) => s === true), 'it sends, like the deployed --watch did')
})
test('coalesced: changes during a pass produce exactly ONE more pass, never two at once', async (t) => {
const { node, base } = fakeNode()
let running = 0
let maxConcurrent = 0
let runs = 0
let release
const project = async () => {
running++; runs++; maxConcurrent = Math.max(maxConcurrent, running)
if (runs === 1) await new Promise((resolve) => { release = resolve })
running--
}
const w = await watchProjection(node, Promise.resolve({ nodes: [{}] }), { project })
t.teardown(() => w.stop())
await tick()
base.emit('update'); base.emit('update'); base.emit('update')
await tick()
release(); await tick(); await tick()
t.is(maxConcurrent, 1, 'never two passes at once')
t.is(runs, 2, 'the three changes during the first pass became one more pass')
})
test('a failing pass does not stop the watch', async (t) => {
const { node, base } = fakeNode()
let runs = 0
const said = []
const w = await watchProjection(node, Promise.resolve({ nodes: [{}] }), { project: async () => { runs++; if (runs === 1) throw new Error('sink respawning') }, log: (m) => said.push(m) })
t.teardown(() => w.stop())
await tick()
base.emit('update'); await tick()
t.is(runs, 2, 'the next change still projects')
t.ok(said.some((m) => /sink respawning/.test(m)), 'the failure was said')
})
test('stop() detaches from the feed', async (t) => {
const { node, base } = fakeNode()
let runs = 0
const w = await watchProjection(node, Promise.resolve({ nodes: [{}] }), { project: async () => { runs++ } })
await tick()
w.stop()
base.emit('update'); await tick()
t.is(runs, 1, 'only the boot pass')
})
// ⛔ AGAINST A REAL FEED. The tests above drive a fake emitter, and they passed while the watch was dead:
// autobee 2.12.1 emits 'update' ONLY on an interrupt (index.js:676), never when the view moves, so on
// tabertron the projection ran once at boot and never again. A post lands as an 'append' on a WRITER
// core — the trigger ap-bridge's FeedHolder already uses for held favela feeds.
test('a real post on a real favela feed triggers a projection pass', async (t) => {
const { FavelaNode } = await import('favela-peer/lib/node.js')
const { dispatch } = await import('favela-peer/lib/rpc.js')
const { identity } = await import('favela-peer/test/helpers.js')
const fs = (await import('bare-fs')).default
const os = (await import('bare-os')).default
const { join } = await import('bare-path')
const storage = join(os.tmpdir(), 'fh-watch-real-' + Date.now() + '-' + Math.random().toString(16).slice(2))
fs.mkdirSync(storage, { recursive: true })
const n = new FavelaNode(storage, { transport: { async join () {}, async leave () {}, async destroy () {} } })
await n.ready()
t.teardown(() => n.close())
const id = await identity()
await n.link(await id.device())
let runs = 0
const w = await watchProjection(n, Promise.resolve({ nodes: [{}] }), { project: async () => { runs++ } })
t.teardown(() => w.stop())
await tick()
t.is(runs, 1, 'the boot pass')
await dispatch(n, 'post', { body: 'hello' })
for (let i = 0; i < 50 && runs < 2; i++) await tick()
t.ok(runs >= 2, 'the post triggered a pass (runs=' + runs + ')')
})
// ⛔ A REFUSAL IS SAID, ONCE. projectBsky returns { refused } without logging when it cannot choose an
// identity — on tabertron (two DIDs, bsky on, none designated) every pass refused and the log said
// nothing at all. Said once per distinct reason, so a refusing node does not spam every pass.
test('a refused pass is logged once per reason', async (t) => {
const { node, base } = fakeNode()
const said = []
const w = await watchProjection(node, Promise.resolve({ nodes: [{}, {}] }), { project: async () => ({ refused: 'several identities, none designated' }), log: (m) => said.push(m) })
t.teardown(() => w.stop())
await tick()
base.emit('update'); await tick()
base.emit('update'); await tick()
const lines = said.filter((m) => /several identities/.test(m))
t.is(lines.length, 1, 'said once, not per pass')
})