// favela appview — reply threading.
//
// A reply is an ORDINARY post whose record carries reply: { root, parent } as strongRefs, so there is no
// new collection to ingest — only an index off the parent uri. Zero of the 199 posts on disk carried a
// reply field when this was built, which is why the composer had to land in the same change: a read side
// with no data cannot be seen working, and "it renders nothing" is indistinguishable from "it is broken".
//
// Run: `bare test/appview-replies.test.mjs`
import '../lib/bare-globals.js'
import { storeDir } from './_store-dir.mjs'
import Corestore from 'corestore'
import { Appview, INGEST_VERSION } from '../lib/appview.js'
import { mkGetRecordStore } from './mk-record-store.mjs'
const assert = (cond, msg) => { if (!cond) throw new Error('FAIL: ' + msg); console.log(' ok -', msg) }
const dir = storeDir('appview-replies-test')
const av = await new Appview(new Corestore(dir), { getRecordStore: mkGetRecordStore(dir + '/rec') }).ready()
const A = 'did:plc:alice'
const B = 'did:plc:bob'
const ROOT = `at://${A}/app.bsky.feed.post/root1`
const ref = (uri) => ({ uri, cid: 'bafyreiaaa' })
const post = (text, createdAt, reply = null) => ({ $type: 'app.bsky.feed.post', text, createdAt, ...(reply ? { reply } : {}) })
await av.applyCommit(A, [{ action: 'create', collection: 'app.bsky.feed.post', rkey: 'root1', record: post('the root', '2026-07-20T00:00:00.000Z') }])
assert((await av.replyCount(ROOT)) === 0, 'a post with no replies counts zero')
assert((await av.getReplies(ROOT)).length === 0, 'and lists none')
// A direct reply
await av.applyCommit(B, [{ action: 'create', collection: 'app.bsky.feed.post', rkey: 'r1', record: post('first', '2026-07-20T01:00:00.000Z', { root: ref(ROOT), parent: ref(ROOT) }) }])
assert((await av.replyCount(ROOT)) === 1, 'a reply is counted against its parent')
const rs = await av.getReplies(ROOT)
assert(rs.length === 1 && rs[0].rkey === 'r1', 'and listed')
assert(rs[0].record.reply.parent.uri === ROOT, 'carrying its parent ref, so render can say "in reply to"')
// Forward-chron: a conversation reads oldest-first, unlike a timeline.
await av.applyCommit(A, [{ action: 'create', collection: 'app.bsky.feed.post', rkey: 'r2', record: post('second', '2026-07-20T02:00:00.000Z', { root: ref(ROOT), parent: ref(ROOT) }) }])
const ordered = await av.getReplies(ROOT)
assert(ordered.map((p) => p.rkey).join(',') === 'r1,r2', 'replies read oldest-first')
// Replay idempotence — same reason likes are not counters.
await av.applyCommit(B, [{ action: 'create', collection: 'app.bsky.feed.post', rkey: 'r1', record: post('first', '2026-07-20T01:00:00.000Z', { root: ref(ROOT), parent: ref(ROOT) }) }])
assert((await av.replyCount(ROOT)) === 2, 'replaying a reply does not inflate the count')
// A reply to a REPLY hangs off its own parent, not the root — otherwise a thread renders flat.
const R1 = `at://${B}/app.bsky.feed.post/r1`
await av.applyCommit(A, [{ action: 'create', collection: 'app.bsky.feed.post', rkey: 'r3', record: post('nested', '2026-07-20T03:00:00.000Z', { root: ref(ROOT), parent: ref(R1) }) }])
assert((await av.replyCount(ROOT)) === 2, 'a nested reply does NOT count against the root')
assert((await av.replyCount(R1)) === 1, 'it counts against the post it answers')
// Deleting a reply must clear the thread edge, or the parent keeps a phantom.
assert((await av.delPost({ did: B, rkey: 'r1' })) === true, 'a reply can be deleted')
assert((await av.replyCount(ROOT)) === 1, 'and the parent count drops')
assert((await av.getReplies(ROOT)).length === 1, 'with no phantom left in the listing')
// Hydration carries the count so render does no lookups.
const tl = await av.getTimeline({ limit: 10 })
const root = tl.posts.find((p) => p.rkey === 'root1')
assert(root && root.replyCount === 1, 'a hydrated post carries its reply count')
// A malformed parent must not create an edge that renders as a broken link.
await av.putPost({ did: A, collection: 'app.bsky.feed.post', rkey: 'bad', record: post('x', '2026-07-20T04:00:00.000Z', { root: ref(ROOT), parent: { uri: 'not-a-uri', cid: 'x' } }) })
assert((await av.replyCount('not-a-uri')) === 0, 'a non-at:// parent is not indexed')
assert(INGEST_VERSION >= 4, 'INGEST_VERSION is at least 4 — existing peers re-fold to pick up thread edges')
await av.close()
console.log('\nAPPVIEW-REPLIES OK: thread edges indexed, forward-chron, nesting + delete correct')