// favela appview — social graph: likes + follows.
//
// Both collections were already being WRITTEN into repos (wj62 had 1 like and 3 follows on disk) and
// indexed by nothing: POST_COLLECTIONS covered only the two post collections, so applyCommit discarded
// them exactly as it discarded profiles.
//
// The load-bearing design choice here is that neither is stored as a COUNTER. A counter is an increment,
// and increments are not idempotent under replay — the INGEST_VERSION backfill re-reads a peer's whole
// firehose, so every like would be recounted on every version bump. Keys are shaped so the natural read
// is a range scan and every write lands on the same key twice. These tests pin that: applying the same
// commit repeatedly must not move the count.
//
// Run: `bare test/appview-graph.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-graph-test')
const av = await new Appview(new Corestore(dir), { getRecordStore: mkGetRecordStore(dir + '/rec') }).ready()
const ALICE = 'did:plc:alice'
const BOB = 'did:plc:bob'
const POST = 'at://did:plc:carol/app.bsky.feed.post/p1'
const like = (uri) => ({ $type: 'app.bsky.feed.like', subject: { uri, cid: 'bafyreiaaa' }, createdAt: '2026-07-20T00:00:00.000Z' })
const follow = (subject) => ({ $type: 'app.bsky.graph.follow', subject, createdAt: '2026-07-20T00:00:00.000Z' })
// LIKES — arrive on the firehose like anything else and used to be dropped.
assert((await av.applyCommit(ALICE, [{ action: 'create', collection: 'app.bsky.feed.like', rkey: 'l1', record: like(POST) }])) === 1, 'a like commit is applied')
assert((await av.likeCount(POST)) === 1, 'and counted against the liked post')
assert((await av.hasLiked(ALICE, POST)) === true, 'the liker is recorded')
assert((await av.hasLiked(BOB, POST)) === false, 'someone who did not like it is not')
// IDEMPOTENCE UNDER REPLAY — the whole reason this is not a counter.
await av.applyCommit(ALICE, [{ action: 'create', collection: 'app.bsky.feed.like', rkey: 'l1', record: like(POST) }])
await av.applyCommit(ALICE, [{ action: 'create', collection: 'app.bsky.feed.like', rkey: 'l1', record: like(POST) }])
assert((await av.likeCount(POST)) === 1, 'replaying the SAME like does not inflate the count')
await av.applyCommit(BOB, [{ action: 'create', collection: 'app.bsky.feed.like', rkey: 'l9', record: like(POST) }])
assert((await av.likeCount(POST)) === 2, 'a different liker does increase it')
// A delete commit carries only the rkey, never the subject — removal has to work from that alone.
assert((await av.applyCommit(ALICE, [{ action: 'delete', collection: 'app.bsky.feed.like', rkey: 'l1' }])) === 1, 'an unlike is applied')
assert((await av.likeCount(POST)) === 1, 'and the count drops')
assert((await av.hasLiked(ALICE, POST)) === false, 'the liker is forgotten')
// FOLLOWS — subject is a bare DID, and the reverse index makes follower counts possible.
await av.applyCommit(ALICE, [{ action: 'create', collection: 'app.bsky.graph.follow', rkey: 'f1', record: follow(BOB) }])
await av.applyCommit(ALICE, [{ action: 'create', collection: 'app.bsky.graph.follow', rkey: 'f2', record: follow('did:plc:carol') }])
assert((await av.following(ALICE)).length === 2, 'following lists both subjects')
assert((await av.following(ALICE)).includes(BOB), 'including bob')
assert((await av.followers(BOB)).includes(ALICE), 'and the reverse index sees alice as a follower of bob')
await av.applyCommit(ALICE, [{ action: 'create', collection: 'app.bsky.graph.follow', rkey: 'f1', record: follow(BOB) }])
assert((await av.following(ALICE)).length === 2, 'replaying a follow does not duplicate it')
assert((await av.applyCommit(ALICE, [{ action: 'delete', collection: 'app.bsky.graph.follow', rkey: 'f1' }])) === 1, 'an unfollow is applied')
assert((await av.following(ALICE)).length === 1, 'following shrinks')
assert((await av.followers(BOB)).length === 0, 'and the reverse index is cleaned up too — not left orphaned')
// Malformed records must be refused rather than indexed as junk that later renders as a phantom like.
assert((await av.putLike({ did: ALICE, rkey: 'x', record: { subject: {} } })) === false, 'a like with no subject uri is refused')
assert((await av.putFollow({ did: ALICE, rkey: 'x', record: { subject: 'not-a-did' } })) === false, 'a follow whose subject is not a did is refused')
// Hydration carries the count so the render layer does no lookups.
await av.applyCommit('did:plc:carol', [{ action: 'create', collection: 'app.bsky.feed.post', rkey: 'p1', record: { $type: 'app.bsky.feed.post', text: 'hi', createdAt: '2026-07-20T00:00:00.000Z' } }])
const tl = await av.getTimeline({ limit: 5 })
const p = tl.posts.find((x) => x.rkey === 'p1')
assert(p && p.likeCount === 1, 'a hydrated post carries its like count')
assert(INGEST_VERSION >= 3, 'INGEST_VERSION is at least 3 — existing peers replay to pick up graph records')
// REPOSTS — same strongRef subject and the same not-a-counter design as likes.
const repost = (uri) => ({ $type: 'app.bsky.feed.repost', subject: { uri, cid: 'bafyreiaaa' }, createdAt: '2026-07-20T00:00:00.000Z' })
assert((await av.applyCommit(ALICE, [{ action: 'create', collection: 'app.bsky.feed.repost', rkey: 'rp1', record: repost(POST) }])) === 1, 'a repost commit is applied')
assert((await av.repostCount(POST)) === 1, 'and counted against the post')
assert((await av.hasReposted(ALICE, POST)) === true, 'the reposter is recorded')
assert((await av.repostRkey(ALICE, POST)) === 'rp1', 'and its rkey is retrievable — an unrepost needs it')
await av.applyCommit(ALICE, [{ action: 'create', collection: 'app.bsky.feed.repost', rkey: 'rp1', record: repost(POST) }])
assert((await av.repostCount(POST)) === 1, 'replaying the same repost does not inflate the count')
assert((await av.applyCommit(ALICE, [{ action: 'delete', collection: 'app.bsky.feed.repost', rkey: 'rp1' }])) === 1, 'an unrepost is applied')
assert((await av.repostCount(POST)) === 0, 'and the count drops to zero')
assert((await av.putRepost({ did: ALICE, rkey: 'x', record: { subject: {} } })) === false, 'a repost with no subject uri is refused')
// Likes and reposts are INDEPENDENT — sharing a subject must not let one clobber the other.
await av.applyCommit(BOB, [{ action: 'create', collection: 'app.bsky.feed.like', rkey: 'L', record: like(POST) }])
await av.applyCommit(BOB, [{ action: 'create', collection: 'app.bsky.feed.repost', rkey: 'R', record: repost(POST) }])
assert((await av.likeCount(POST)) === 2 && (await av.repostCount(POST)) === 1, 'like and repost counts are independent')
await av.close()
console.log('\nAPPVIEW-GRAPH OK: likes + follows indexed, replay-idempotent, reverse index consistent')