// favela appview — profile indexing + author hydration.
//
// atproto's shape: an AppView consumes the firehose and indexes app.bsky.actor.profile alongside
// app.bsky.feed.post, then serves feeds with the author ALREADY hydrated. Clients never resolve authors.
//
// favela did the first half only. Profile commits arrived on the same firehose and were dropped by the
// POST_COLLECTIONS filter in applyCommit, so every node could render its OWN profile and nobody else's —
// remote authors were a raw `did:plc:…` forever. Measured on tabertron: it held 62 of e2jev's post
// records and zero of its profile, while e2jev's own node had displayName "Ashatron", a bio and an
// avatar blob sitting right there.
//
// These pin the fix at the layer that owns it, so a future filter change cannot silently re-drop them.
// Run: `bare test/appview-profile.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-profile-test')
const store = new Corestore(dir)
const recRoot = dir + '/rec'
const getRecordStore = mkGetRecordStore(recRoot)
const av = await new Appview(store, { getRecordStore }).ready()
const DID = 'did:plc:ashatron'
const AVATAR = { $type: 'blob', ref: { $link: 'bafkreigfxj4c2gqzjfpged5lmz6sbq3kiws7bmpx52qxbkmytdqq52qqjy' }, mimeType: 'image/jpeg', size: 1234 }
const PROFILE = { $type: 'app.bsky.actor.profile', displayName: 'Ashatron', description: 'Self-custody atproto peer', avatar: AVATAR }
// A profile arriving on the firehose is INDEXED, not discarded. This is the whole bug.
const n = await av.applyCommit(DID, [{ action: 'create', collection: 'app.bsky.actor.profile', rkey: 'self', record: PROFILE }])
assert(n === 1, 'a profile commit is applied (it used to be silently dropped)')
const prof = await av.getProfile(DID)
assert(prof && prof.displayName === 'Ashatron', 'displayName is indexed')
assert(prof.description === 'Self-custody atproto peer', 'description is indexed')
assert(prof.avatar && prof.avatar.ref, 'the RAW blob ref is kept — normalizing it is the render layer\'s job')
// REPLICATION INTO HYPER-OVERLAY: the record bytes must land in the shared content-addressed store,
// exactly as posts do, or a remote profile stays stranded on its home node with only an index entry
// here. This is the half that makes the profile actually travel.
const rs = await getRecordStore(DID)
const onDisk = await rs.get('app.bsky.actor.profile', 'self')
assert(onDisk && onDisk.displayName === 'Ashatron', 'the profile record BYTES are written to the shared overlay store')
// Hydration: the author rides the view, so the render layer does no lookups of its own.
await av.applyCommit(DID, [{ action: 'create', collection: 'app.bsky.feed.post', rkey: 'p1', record: { $type: 'app.bsky.feed.post', text: 'hello', createdAt: '2026-07-20T00:00:00.000Z' } }])
const tl = await av.getTimeline({ limit: 10 })
assert(tl.posts.length === 1, 'the post is in the timeline')
assert(tl.posts[0].author && tl.posts[0].author.displayName === 'Ashatron', 'the timeline post carries its author, hydrated')
assert(tl.posts[0].author.avatar && tl.posts[0].author.avatar.ref, 'and the avatar ref, so a byline can render a pfp')
// An author with NO profile must hydrate to a usable shape rather than undefined — the render degrades
// to a short DID, which is what every remote author looked like before this change.
const BARE = 'did:plc:noprofile'
await av.applyCommit(BARE, [{ action: 'create', collection: 'app.bsky.feed.post', rkey: 'p2', record: { $type: 'app.bsky.feed.post', text: 'hi', createdAt: '2026-07-21T00:00:00.000Z' } }])
const tl2 = await av.getTimeline({ limit: 10 })
const bare = tl2.posts.find((p) => p.did === BARE)
assert(bare && bare.author && bare.author.did === BARE, 'a profile-less author still gets an author object')
assert(bare.author.displayName === null, 'with a null displayName rather than undefined')
// Dedup gate: Hyperbee subs do not dedup and indexPeer re-folds on every reconnect, so an unchanged
// re-put must be a no-op or the index balloons — the same failure putPost was hardened against.
assert((await av.putProfile({ did: DID, record: PROFILE })) === false, 'an unchanged profile re-put is skipped')
const edited = { ...PROFILE, displayName: 'Ashatron 2' }
assert((await av.putProfile({ did: DID, record: edited })) === true, 'a CHANGED profile is written')
assert((await av.getProfile(DID)).displayName === 'Ashatron 2', 'and the memo is invalidated, not stale')
// Delete
assert((await av.applyCommit(DID, [{ action: 'delete', collection: 'app.bsky.actor.profile', rkey: 'self' }])) === 1, 'a profile delete is applied')
assert((await av.getProfile(DID)) === null, 'and the profile is gone')
// ── backfill gate ────────────────────────────────────────────────────────────────────────────────
// The coHosted fast path tails from the current END, which is self-fulfilling: our own putPost writes
// ingested records into the shared store, so the first ingest makes it non-empty and the DID is treated
// as co-hosted forever after. e2jev replayed from 0 exactly once — while applyCommit still dropped
// profiles — and could never do it again, leaving its profile stranded in unread firehose history.
// INGEST_VERSION forces ONE replay per DID whenever this build learns to index more.
const FRESH = 'did:plc:neverfolded'
assert((await av.needsBackfill(FRESH)) === true, 'a DID never folded needs a backfill')
assert((await av.ingestVersion(FRESH)) === 0, 'and reports version 0')
await av.markBackfilled(FRESH)
assert((await av.ingestVersion(FRESH)) === INGEST_VERSION, 'marking records the current version')
assert((await av.needsBackfill(FRESH)) === false, 'and it no longer needs one')
// The whole point: a DID folded at an OLDER version must replay again, because the older build discarded
// records this one keeps. Without this, widening the filter silently never applies to existing peers.
await av.markBackfilled(FRESH, 1)
assert((await av.needsBackfill(FRESH)) === true, 'a DID folded at an older version needs a fresh replay')
assert(INGEST_VERSION >= 2, 'INGEST_VERSION is at least 2 — profiles are indexed in this build')
await av.close()
console.log('\nAPPVIEW-PROFILE OK: firehose profile ingest + overlay write-through + author hydration')