diff --git a/psf-memo-db/acceptance/lib/handlers.js b/psf-memo-db/acceptance/lib/handlers.js index 4c40efc..f88bc17 100644 --- a/psf-memo-db/acceptance/lib/handlers.js +++ b/psf-memo-db/acceptance/lib/handlers.js @@ -33,6 +33,7 @@ import GetPollVotes from '../../src/use-cases/get-poll-votes.js' import { repairTxidEncoding } from '../../src/lib/repair-txid-encoding.js' import { backfillTopicIndexes, topicRecencyKey } from '../../src/lib/backfill-topic-indexes.js' import { backfillFolloweeIndex, followeeHeightKey } from '../../src/lib/backfill-followee-index.js' +import { backfillProfileRecency } from '../../src/lib/backfill-profile-recency.js' import BCHJS from '@psf/bch-js' const bchjs = new BCHJS({ restURL: process.env.RESTURL || 'https://api.fullstack.cash/v5/' }) @@ -335,6 +336,16 @@ async function loadFixture (world, name) { return } + if (name === 'profiles-with-post-recency') { + await loadProfilesWithPostRecency(world) + return + } + + if (name === 'profiles-with-post-history') { + await loadProfilesWithPostHistory(world) + return + } + if (name !== 'three-top-level-posts-and-one-reply') { throw new Error(`Unknown fixture: ${name}`) } @@ -537,6 +548,15 @@ async function loadProfilesWithIdentities (world) { }) } + const recency = [ + { addr: 'bitcoincash:qaddr-alice', blockHeight: 600300, seen: 3 }, + { addr: 'bitcoincash:qaddr-bob', blockHeight: 600200, seen: 2 }, + { addr: 'bitcoincash:qaddr-carol', blockHeight: 600100, seen: 1 } + ] + for (const record of recency) { + await world.adapters.level.profileRecencyDb.put(record.addr, record) + } + const names = [ { addr: 'bitcoincash:qaddr-alice', name: 'alice', txid: 'name-alice', blockHeight: 600250 }, { addr: 'bitcoincash:qaddr-carol', name: 'carol', txid: 'name-carol', blockHeight: 600050 } @@ -562,6 +582,85 @@ async function loadProfilesWithIdentities (world) { } } +// Fixture "profiles-with-post-recency" from recent-profile-ordering.feature: +// five profiles with distinct set-profile block heights/seens, and four +// profileRecency records whose values deliberately differ from the profile +// records so the two sources cannot be confused. Dave never posted. +async function loadProfilesWithPostRecency (world) { + const profiles = [ + { addr: 'bitcoincash:qaddr-alice', text: 'alice bio', txid: 'profile-alice', blockHeight: 600010, seen: 10 }, + { addr: 'bitcoincash:qaddr-bob', text: 'bob bio', txid: 'profile-bob', blockHeight: 600020, seen: 20 }, + { addr: 'bitcoincash:qaddr-erin', text: 'erin bio', txid: 'profile-erin', blockHeight: 600030, seen: 30 }, + { addr: 'bitcoincash:qaddr-carol', text: 'carol bio', txid: 'profile-carol', blockHeight: 600040, seen: 40 }, + { addr: 'bitcoincash:qaddr-dave', text: 'dave bio', txid: 'profile-dave', blockHeight: 600050, seen: 50 } + ] + for (const profile of profiles) { + await world.adapters.level.profilesDb.put(profile.addr, profile) + } + + const recency = [ + { addr: 'bitcoincash:qaddr-alice', blockHeight: 600300, seen: 300 }, + { addr: 'bitcoincash:qaddr-bob', blockHeight: 600300, seen: 200 }, + { addr: 'bitcoincash:qaddr-erin', blockHeight: 600300, seen: 200 }, + { addr: 'bitcoincash:qaddr-carol', blockHeight: 600200, seen: 400 } + ] + for (const record of recency) { + await world.adapters.level.profileRecencyDb.put(record.addr, record) + } +} + +function pad (blockHeight) { + return String(blockHeight ?? 0).padStart(12, '0') +} + +// Fixture "profiles-with-post-history" from backfill-profile-recency.feature: +// raw profiles/posts/addrPostHeights the backfill projects into profileRecency, +// including a reply, a poll, and an unconfirmed post that must all be ignored. +async function loadProfilesWithPostHistory (world) { + const profiles = [ + { addr: 'bitcoincash:qaddr-alice', text: 'alice bio', txid: 'profile-alice' }, + { addr: 'bitcoincash:qaddr-bob', text: 'bob bio', txid: 'profile-bob' }, + { addr: 'bitcoincash:qaddr-nopost', text: 'nopost bio', txid: 'profile-nopost' } + ] + for (const profile of profiles) { + await world.adapters.level.profilesDb.put(profile.addr, profile) + } + + const posts = [ + { txid: 'post-a1', addr: 'bitcoincash:qaddr-alice', seen: 100, blockHeight: 600100 }, + { txid: 'reply-a1', addr: 'bitcoincash:qaddr-alice', seen: 150, blockHeight: 600300 }, + { txid: 'post-b1', addr: 'bitcoincash:qaddr-bob', seen: 200, blockHeight: 600400 }, + { txid: 'post-b2', addr: 'bitcoincash:qaddr-bob', seen: 250, blockHeight: 600500 } + ] + for (const post of posts) { + await world.adapters.level.postsDb.put(post.txid, post) + } + + const addrPostHeights = [ + { addr: 'bitcoincash:qaddr-alice', txid: 'post-a1', blockHeight: 600100 }, + { addr: 'bitcoincash:qaddr-alice', txid: 'reply-a1', blockHeight: 600300 }, + { addr: 'bitcoincash:qaddr-alice', txid: 'poll-a1', blockHeight: 600200 }, + { addr: 'bitcoincash:qaddr-bob', txid: 'post-b1', blockHeight: 600400 }, + { addr: 'bitcoincash:qaddr-bob', txid: 'post-b2', blockHeight: 600500 } + ] + for (const entry of addrPostHeights) { + await world.adapters.level.addrPostHeightsDb.put( + `${entry.addr}:${pad(entry.blockHeight)}:${entry.txid}`, + entry + ) + } + + await world.adapters.level.postParentsDb.put('reply-a1', { + txid: 'reply-a1', parentTxid: 'post-a1', childTxid: 'reply-a1', blockHeight: 600300 + }) + await world.adapters.level.pollsDb.put('poll-a1', { + addr: 'bitcoincash:qaddr-alice', pollType: 1, optionCount: 2, question: 'which?', seen: 120, blockHeight: 600200 + }) + await world.adapters.level.statusDb.put('status', { + startBlockHeight: 0, syncedBlockHeight: 600450, chainBlockHeight: 600450 + }) +} + async function loadFollowingFeedCapped (world) { const viewer = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d' const followee = 'bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy' @@ -2288,6 +2387,164 @@ const handlers = [ throw new Error(`Expected postParents store not to be iterated, got ${world.postParentsIteratorCounter.calls} call(s)`) } } + }, + { + name: 'db instance with profiles and profileRecency stores', + pattern: /^a psf-memo-db instance with profiles and profileRecency stores$/, + async run () { + // World is already created with both stores. + } + }, + { + name: 'db instance with profiles, names, profilePics, and profileRecency stores', + pattern: /^a psf-memo-db instance with profiles, names, profilePics, and profileRecency stores$/, + async run () { + // World is already created with all four stores. + } + }, + { + name: 'db instance with profiles, posts, addrPostHeights, postParents, polls, status, and profileRecency stores', + pattern: /^a psf-memo-db instance with profiles, posts, addrPostHeights, postParents, polls, status, and profileRecency stores$/, + async run () { + // World is already created with all stores. + } + }, + { + name: 'load fixture into profiles and profileRecency stores', + pattern: /^the fixture "(.+)" is loaded into the profiles and profileRecency stores$/, + async run (m, example, world) { + await loadFixture(world, m[1]) + } + }, + { + name: 'load fixture into profiles, names, profilePics, and profileRecency stores', + pattern: /^the fixture "(.+)" is loaded into the profiles, names, profilePics, and profileRecency stores$/, + async run (m, example, world) { + await loadFixture(world, m[1]) + } + }, + { + name: 'load fixture into profiles, posts, addrPostHeights, postParents, polls, status, and profileRecency stores', + pattern: /^the fixture "(.+)" is loaded into the profiles, posts, addrPostHeights, postParents, polls, status, and profileRecency stores$/, + async run (m, example, world) { + await loadFixture(world, m[1]) + } + }, + { + name: 'request recent profiles with limit and offset', + pattern: /^the client requests \/profile\/recent with limit (\S+) and offset (\S+)$/, + async run (m, example, world) { + const limit = parseInt(resolveParam(m[1], example), 10) + const offset = parseInt(resolveParam(m[2], example), 10) + const resp = await world.listRecentProfiles.execute({ limit, offset }) + world.setLastResponse(resp) + } + }, + { + name: 'response lists profiles in order', + pattern: /^the response lists profiles in order \((.+)\)$/, + run (m, example, world) { + const expected = resolveParam(m[1], example).split(',').map((s) => s.trim()) + const actual = world.getLastResponse().profiles.map((p) => p.addr) + if (expected.join(',') !== actual.join(',')) { + throw new Error(`Expected profiles ${expected.join(',')}, got ${actual.join(',')}`) + } + } + }, + { + name: 'response profile has block height and seen', + pattern: /^the response profile for (.+) has block height (\S+) and seen (\S+)$/, + run (m, example, world) { + const addr = resolveParam(m[1], example) + const height = parseInt(resolveParam(m[2], example), 10) + const seen = parseInt(resolveParam(m[3], example), 10) + const profile = world.getLastResponse().profiles.find((p) => p.addr === addr) + if (!profile) { + throw new Error(`No response profile for ${addr}`) + } + if (profile.blockHeight !== height || profile.seen !== seen) { + throw new Error(`Expected ${addr} at ${height} seen ${seen}, got ${profile.blockHeight} seen ${profile.seen}`) + } + } + }, + { + name: 'response does not list addr', + pattern: /^the response does not list (.+)$/, + run (m, example, world) { + const addr = resolveParam(m[1], example) + const found = world.getLastResponse().profiles.find((p) => p.addr === addr) + if (found) { + throw new Error(`Expected response not to list ${addr}`) + } + } + }, + { + name: 'addrPostHeights store was not iterated', + pattern: /^the addrPostHeights store was not iterated$/, + run (m, example, world) { + if (world.addrPostHeightsIteratorCounter.calls !== 0) { + throw new Error(`Expected addrPostHeights store not to be iterated, got ${world.addrPostHeightsIteratorCounter.calls} call(s)`) + } + } + }, + { + name: 'run profile recency backfill utility', + pattern: /^the profile recency backfill utility is run$/, + async run (m, example, world) { + await backfillProfileRecency({ + profilesDb: world.adapters.level.profilesDb, + postsDb: world.adapters.level.postsDb, + addrPostHeightsDb: world.adapters.level.addrPostHeightsDb, + postParentsDb: world.adapters.level.postParentsDb, + pollsDb: world.adapters.level.pollsDb, + statusDb: world.adapters.level.statusDb, + profileRecencyDb: world.adapters.level.profileRecencyDb + }) + } + }, + { + name: 'run profile recency backfill utility again', + pattern: /^the profile recency backfill utility is run again$/, + async run (m, example, world) { + await backfillProfileRecency({ + profilesDb: world.adapters.level.profilesDb, + postsDb: world.adapters.level.postsDb, + addrPostHeightsDb: world.adapters.level.addrPostHeightsDb, + postParentsDb: world.adapters.level.postParentsDb, + pollsDb: world.adapters.level.pollsDb, + statusDb: world.adapters.level.statusDb, + profileRecencyDb: world.adapters.level.profileRecencyDb + }) + } + }, + { + name: 'profileRecency records addr at height seen', + pattern: /^the profileRecency store records (.+) at block height (\S+) seen at (\S+)$/, + async run (m, example, world) { + const addr = resolveParam(m[1], example) + const height = parseInt(resolveParam(m[2], example), 10) + const seen = parseInt(resolveParam(m[3], example), 10) + let record + try { + record = await world.adapters.level.profileRecencyDb.get(addr) + } catch (err) { + throw new Error(`No profileRecency record for ${addr}`) + } + if (record.blockHeight !== height || record.seen !== seen) { + throw new Error(`Expected profileRecency ${addr} at ${height} seen ${seen}, got ${JSON.stringify(record)}`) + } + } + }, + { + name: 'profileRecency has no record for addr', + pattern: /^the profileRecency store has no record for (.+)$/, + async run (m, example, world) { + const addr = resolveParam(m[1], example) + const record = await world.adapters.level.profileRecencyDb.get(addr).catch(() => null) + if (record) { + throw new Error(`Expected no profileRecency record for ${addr}, got ${JSON.stringify(record)}`) + } + } } ] diff --git a/psf-memo-db/src/adapters/index.js b/psf-memo-db/src/adapters/index.js index 9227f3a..e4041f9 100644 --- a/psf-memo-db/src/adapters/index.js +++ b/psf-memo-db/src/adapters/index.js @@ -29,7 +29,8 @@ class Adapters { this.profileQuery = new ProfileQuery({ profilesDb: level.profilesDb, namesDb: level.namesDb, - profilePicsDb: level.profilePicsDb + profilePicsDb: level.profilePicsDb, + profileRecencyDb: level.profileRecencyDb }) // muteQuery must be constructed before postQuery: downstream adapters read // this.muteQuery at construction time, so declaring postQuery first would diff --git a/psf-memo-db/src/adapters/level-db.js b/psf-memo-db/src/adapters/level-db.js index ad89617..7084a8d 100644 --- a/psf-memo-db/src/adapters/level-db.js +++ b/psf-memo-db/src/adapters/level-db.js @@ -21,6 +21,7 @@ const DB_NAMES = [ 'names', 'profiles', 'profilePics', + 'profileRecency', 'follows', 'followeeHeights', 'mutes', diff --git a/psf-memo-db/src/adapters/profile-query.js b/psf-memo-db/src/adapters/profile-query.js index abe47cf..bce246a 100644 --- a/psf-memo-db/src/adapters/profile-query.js +++ b/psf-memo-db/src/adapters/profile-query.js @@ -1,35 +1,87 @@ /* - Adapter for scanning profiles with stored block height and joining the - address-keyed display name and avatar URL. + Adapter for the recent-profiles read path. + + Recent profiles are ordered by the profile's most recent qualifying post, not + by the set-profile transaction. The indexer maintains `profileRecency`, one + record per profile address that has at least one qualifying post, keyed by + address with `{ addr, blockHeight, seen }`. This adapter reads that index, + orders the records by block height descending, then seen descending, then + address ascending, and does a point lookup of each returned profile's text + and provenance. It joins the address-keyed display name and avatar URL the + same way. + + Reading the recency index (plus per-row profile lookups) means the query + never scans the addrPostHeights store and never sorts the whole profiles + store. */ class ProfileQuery { constructor (localConfig = {}) { - const { profilesDb, namesDb, profilePicsDb } = localConfig + const { profilesDb, namesDb, profilePicsDb, profileRecencyDb } = localConfig if (!profilesDb) { throw new Error('profilesDb required when instantiating ProfileQuery adapter.') } this.profilesDb = profilesDb this.namesDb = namesDb || null this.profilePicsDb = profilePicsDb || null - this.scanProfilesWithBlockHeight = this.scanProfilesWithBlockHeight.bind(this) + this.profileRecencyDb = profileRecencyDb || null + this.listRecentProfiles = this.listRecentProfiles.bind(this) + this.listRecencyEntries = this.listRecencyEntries.bind(this) this.getProfileIdentity = this.getProfileIdentity.bind(this) + this.getRecordOrNull = this.getRecordOrNull.bind(this) } - async scanProfilesWithBlockHeight () { - const profiles = [] + // Order the recency records by most recent post. A profile that has never + // posted has no record and is omitted. The order is total: height desc, then + // seen desc, then address asc. + compareRecency (a, b) { + if (b.blockHeight !== a.blockHeight) return b.blockHeight - a.blockHeight + if (b.seen !== a.seen) return b.seen - a.seen + if (a.addr === b.addr) return 0 + return a.addr < b.addr ? -1 : 1 + } - for await (const [addr, profile] of this.profilesDb.iterator()) { - profiles.push({ - addr, - text: profile.text, - txid: profile.txid, - seen: profile.seen, - blockHeight: profile.blockHeight ?? 0 + // Read every recency record. This is the index the read side orders from, + // not the profiles store. + async listRecencyEntries () { + const entries = [] + if (!this.profileRecencyDb) return entries + + for await (const [key, value] of this.profileRecencyDb.iterator()) { + entries.push({ + addr: value?.addr ?? String(key), + blockHeight: value?.blockHeight ?? 0, + seen: value?.seen ?? 0 }) } - return profiles + return entries + } + + // Return the requested page of recent profiles plus the total number of + // eligible profiles. Ordering and pagination come from profileRecency; the + // profile text and provenance come from one point lookup per returned row. + async listRecentProfiles ({ limit = 100, offset = 0 } = {}) { + const entries = await this.listRecencyEntries() + entries.sort(this.compareRecency) + + const total = entries.length + const page = entries.slice(offset, offset + limit) + const profiles = [] + + for (const entry of page) { + const profile = await this.getRecordOrNull(this.profilesDb, entry.addr) + if (!profile) continue + profiles.push({ + addr: entry.addr, + text: profile.text, + txid: profile.txid, + blockHeight: entry.blockHeight, + seen: entry.seen + }) + } + + return { profiles, total } } // Join one profile's display name (names store) and avatar URL (profilePics @@ -60,7 +112,3 @@ class ProfileQuery { } export default ProfileQuery - -// mutate4javascript-manifest-begin -// {"version":1,"tested_at":"2026-09-20T20:05:24.208Z","module_hash":"dab2b10155f8ffc0c8b8005a77af81daaf6682dac4eeb1c56ad061d9961d0040","functions":[{"id":"func/ProfileQuery.constructor","name":"ProfileQuery.constructor","line":7,"end_line":17,"hash":"83ff1150629995273a68f8f985067724a6f5c839f2642ee67855d26be61d5f03"},{"id":"func/ProfileQuery.scanProfilesWithBlockHeight","name":"ProfileQuery.scanProfilesWithBlockHeight","line":19,"end_line":33,"hash":"1fa61f0f49063b13c3eb67725c74614e0b798b0ffcd0248385f838317cabfd0c"},{"id":"func/ProfileQuery.getProfileIdentity","name":"ProfileQuery.getProfileIdentity","line":39,"end_line":49,"hash":"66105f1c77dadc97f32beec8c952184617e07a0401eae1af1128de70f47df070"},{"id":"func/ProfileQuery.getRecordOrNull","name":"ProfileQuery.getRecordOrNull","line":51,"end_line":59,"hash":"5604da1e9adc18abda5e58e4f366aa0bcc8e0dd417e2f6882c27289df7fb7a14"}]} -// mutate4javascript-manifest-end diff --git a/psf-memo-db/src/controllers/rest-api/level/crud-handlers.js b/psf-memo-db/src/controllers/rest-api/level/crud-handlers.js index ba131e9..4153515 100644 --- a/psf-memo-db/src/controllers/rest-api/level/crud-handlers.js +++ b/psf-memo-db/src/controllers/rest-api/level/crud-handlers.js @@ -45,6 +45,7 @@ export const ENTITY_CONFIG = [ { route: 'name', dbProp: 'namesDb', keyParam: 'addr', bodyIdField: 'addr', bodyDataField: 'nameData' }, { route: 'profile', dbProp: 'profilesDb', keyParam: 'addr', bodyIdField: 'addr', bodyDataField: 'profileData' }, { route: 'profilepic', dbProp: 'profilePicsDb', keyParam: 'addr', bodyIdField: 'addr', bodyDataField: 'profilePicData' }, + { route: 'profilerecency', dbProp: 'profileRecencyDb', keyParam: 'addr', bodyIdField: 'addr', bodyDataField: 'profileRecencyData' }, { route: 'follow', dbProp: 'followsDb', keyParam: 'key', bodyIdField: 'key', bodyDataField: 'followData' }, { route: 'followeeheight', dbProp: 'followeeHeightsDb', keyParam: 'key', bodyIdField: 'key', bodyDataField: 'followeeHeightData' }, { route: 'room', dbProp: 'roomsDb', keyParam: 'key', bodyIdField: 'key', bodyDataField: 'roomData' }, diff --git a/psf-memo-db/src/lib/backfill-profile-recency.js b/psf-memo-db/src/lib/backfill-profile-recency.js new file mode 100644 index 0000000..59b88cd --- /dev/null +++ b/psf-memo-db/src/lib/backfill-profile-recency.js @@ -0,0 +1,105 @@ +/* + Library to build the profileRecency index from an existing psf-memo-db. + + profileRecency holds one record per profile address that has at least one + confirmed qualifying post, keyed by address, value `{ addr, blockHeight, + seen }`. The read side uses it to serve GET /profile/recent ordered by the + most recent post without scanning addrPostHeights itself. + + A qualifying post is a top-level post (0x6d02) or a topic message (0x6d0c). + Replies (0x6d03) live in postParents and poll creations (0x6d10) live in + polls, so both are excluded. Only addresses that also have a profile record + qualify. Entries above status.chainBlockHeight are unconfirmed and ignored. + The newest confirmed qualifying post wins, with seen as the tie-breaker. + + The backfill rebuilds the whole index from addrPostHeights, is idempotent, + and removes stale records for addresses that no longer qualify. The LevelDB + handles are injected so the logic stays testable and free of file-system + concerns; the CLI wrapper in util/profiles opens the real stores. +*/ + +function isNotFound (err) { + return Boolean(err && (err.notFound || err.code === 'LEVEL_NOT_FOUND')) +} + +// Read a record, or null when it is absent. Rethrows real errors. +async function getRecord (db, key) { + if (!db) return null + try { + return await db.get(key) + } catch (err) { + if (isNotFound(err)) return null + throw err + } +} + +// The status store holds the indexer tip. A missing store or record means we +// cannot prove any entry is unconfirmed, so the backfill keeps every entry. +async function readChainBlockHeight (statusDb) { + const status = await getRecord(statusDb, 'status') + return typeof status?.chainBlockHeight === 'number' ? status.chainBlockHeight : null +} + +// Recover the address, block height, and txid from an addrPostHeights key of +// the form `${addr}:${paddedHeight}:${txid}`. Cash addresses contain colons, so +// the height and txid are the final two segments. +export function partsFromAddrPostHeightKey (key) { + const str = String(key) + const txidColon = str.lastIndexOf(':') + const txid = str.slice(txidColon + 1) + const beforeTxid = str.slice(0, txidColon) + const heightColon = beforeTxid.lastIndexOf(':') + return { + addr: beforeTxid.slice(0, heightColon), + blockHeight: parseInt(beforeTxid.slice(heightColon + 1), 10) || 0, + txid + } +} + +// Keep the newest confirmed qualifying post per address. +export async function backfillProfileRecency ({ + profilesDb, + postsDb, + addrPostHeightsDb, + postParentsDb, + pollsDb, + statusDb, + profileRecencyDb +}) { + const chainBlockHeight = await readChainBlockHeight(statusDb) + const best = new Map() + + for await (const [key, value] of addrPostHeightsDb.iterator()) { + const fallback = partsFromAddrPostHeightKey(key) + const addr = value?.addr ?? fallback.addr + const txid = value?.txid ?? fallback.txid + const blockHeight = value?.blockHeight ?? fallback.blockHeight + if (!addr || !txid) continue + if (chainBlockHeight !== null && blockHeight > chainBlockHeight) continue + if (await getRecord(postParentsDb, txid)) continue + if (await getRecord(pollsDb, txid)) continue + if (!(await getRecord(profilesDb, addr))) continue + + const post = await getRecord(postsDb, txid) + const seen = post?.seen ?? 0 + const current = best.get(addr) + if (!current || blockHeight > current.blockHeight || (blockHeight === current.blockHeight && seen > current.seen)) { + best.set(addr, { addr, blockHeight, seen }) + } + } + + for (const record of best.values()) { + await profileRecencyDb.put(record.addr, record) + } + + const desired = new Set(best.keys()) + const stale = [] + for await (const [key] of profileRecencyDb.iterator()) { + if (!desired.has(key)) stale.push(key) + } + for (const key of stale) { + await profileRecencyDb.del(key) + } + + return { profiles: best.size } +} diff --git a/psf-memo-db/src/use-cases/list-recent-profiles.js b/psf-memo-db/src/use-cases/list-recent-profiles.js index 8cae83b..010c71e 100644 --- a/psf-memo-db/src/use-cases/list-recent-profiles.js +++ b/psf-memo-db/src/use-cases/list-recent-profiles.js @@ -1,10 +1,10 @@ /* - Use case: list profiles ordered by block height (most recent first), paginated. + Use case: list profiles ordered by their most recent qualifying post, + paginated. */ import { parseLimit, parseOffset } from './lib/pagination.js' import { ListUseCase } from './lib/use-case.js' -import { sortByHeightDesc } from '../lib/search.js' class ListRecentProfiles extends ListUseCase { constructor (localConfig = {}) { @@ -15,31 +15,24 @@ class ListRecentProfiles extends ListUseCase { const limit = parseLimit(inObj.limit) const offset = parseOffset(inObj.offset) - const allProfiles = await this.adapters.profileQuery.scanProfilesWithBlockHeight() - const sorted = allProfiles.sort(sortByHeightDesc) - const total = sorted.length - const page = sorted.slice(offset, offset + limit) - const profiles = await Promise.all( - page.map(async (profile) => ({ + const { profiles, total } = await this.adapters.profileQuery.listRecentProfiles({ limit, offset }) + const enriched = await Promise.all( + profiles.map(async (profile) => ({ ...profile, ...(await this.adapters.profileQuery.getProfileIdentity(profile.addr)) })) ) return { - profiles, + profiles: enriched, pagination: { limit, offset, total, - hasMore: offset + profiles.length < total + hasMore: offset + enriched.length < total } } } } export default ListRecentProfiles - -// mutate4javascript-manifest-begin -// {"version":1,"tested_at":"2026-09-20T20:06:31.180Z","module_hash":"5c9bf3b5be5a616a72eef340cf9f1d2c211c061d80aabc4eb2393a73ee77fbfa","functions":[{"id":"func/ListRecentProfiles.constructor","name":"ListRecentProfiles.constructor","line":10,"end_line":12,"hash":"a2c7dd0696ac463cbc142fa7ccce4a3cadf8e246a872261733d199dbd4c153d1"},{"id":"func/ListRecentProfiles.execute","name":"ListRecentProfiles.execute","line":14,"end_line":38,"hash":"4f1c2184766274d911ae3cab174ef9cc946ca9459aad1e199811732ebda99d46"}]} -// mutate4javascript-manifest-end diff --git a/psf-memo-db/test/property/recent-profile-identity.property.test.js b/psf-memo-db/test/property/recent-profile-identity.property.test.js index 35de32c..5e93f15 100644 --- a/psf-memo-db/test/property/recent-profile-identity.property.test.js +++ b/psf-memo-db/test/property/recent-profile-identity.property.test.js @@ -119,11 +119,11 @@ function recentProfilesSetGen () { return { profiles, identities } } -function makeUseCase (profiles, identities, joined) { +function makeUseCase (page, total, identities, joined) { return new ListRecentProfiles({ adapters: { profileQuery: { - scanProfilesWithBlockHeight: async () => profiles.map((profile) => ({ ...profile })), + listRecentProfiles: async () => ({ profiles: page.map((profile) => ({ ...profile })), total }), getProfileIdentity: async (addr) => { joined.push(addr) return identities[addr] @@ -133,7 +133,7 @@ function makeUseCase (profiles, identities, joined) { }) } -test('ListRecentProfiles enriches exactly the requested page without changing order or pagination', async () => { +test('ListRecentProfiles enriches exactly the returned page without changing order or pagination', async () => { await forAll( (i) => { const { profiles, identities } = recentProfilesSetGen() @@ -146,10 +146,9 @@ test('ListRecentProfiles enriches exactly the requested page without changing or }, async ({ profiles, identities, limit, offset }) => { const joined = [] - const result = await makeUseCase(profiles, identities, joined).execute({ limit, offset }) - const expectedOrder = [...profiles].sort(sortByHeightDesc) const expectedPage = expectedOrder.slice(offset, offset + limit) + const result = await makeUseCase(expectedPage, profiles.length, identities, joined).execute({ limit, offset }) if (result.profiles.length !== expectedPage.length) return false if (result.pagination.limit !== limit) return false diff --git a/psf-memo-db/test/unit/adapters/profile-query.unit.js b/psf-memo-db/test/unit/adapters/profile-query.unit.js index e03aab5..63c8d38 100644 --- a/psf-memo-db/test/unit/adapters/profile-query.unit.js +++ b/psf-memo-db/test/unit/adapters/profile-query.unit.js @@ -6,48 +6,121 @@ describe('#ProfileQuery', () => { let uut let sandbox let profilesDb + let profileRecencyDb let namesDb let profilePicsDb beforeEach(() => { sandbox = sinon.createSandbox() - profilesDb = { - iterator: sandbox.stub() - } - namesDb = { - get: sandbox.stub() - } - profilePicsDb = { - get: sandbox.stub() - } - uut = new ProfileQuery({ profilesDb, namesDb, profilePicsDb }) + profilesDb = { get: sandbox.stub(), iterator: sandbox.stub() } + profileRecencyDb = { iterator: sandbox.stub() } + namesDb = { get: sandbox.stub() } + profilePicsDb = { get: sandbox.stub() } + uut = new ProfileQuery({ profilesDb, namesDb, profilePicsDb, profileRecencyDb }) }) afterEach(() => sandbox.restore()) - it('should scan profiles and read block height from stored document', async () => { - async function * mockIterator () { - yield ['addr1', { text: 'hi', txid: 'tx1', seen: 1000, blockHeight: 600100 }] - yield ['addr2', { text: 'bye', txid: 'tx2', seen: 2000, blockHeight: 600200 }] - } - profilesDb.iterator.returns(mockIterator()) + function stubRecency (records) { + profileRecencyDb.iterator.returns((async function * () { + for (const [addr, record] of Object.entries(records)) { + yield [addr, record] + } + })()) + } - const result = await uut.scanProfilesWithBlockHeight() + function stubProfiles (profiles) { + profilesDb.get.callsFake(async (addr) => { + if (Object.prototype.hasOwnProperty.call(profiles, addr)) return profiles[addr] + const err = new Error('not found') + err.notFound = true + throw err + }) + } - assert.equal(result.length, 2) - assert.equal(result[0].blockHeight, 600100) - assert.equal(result[1].blockHeight, 600200) + it('should order profiles by post height descending, then seen descending, then address ascending', async () => { + stubRecency({ + 'bitcoincash:qaddr-alice': { addr: 'bitcoincash:qaddr-alice', blockHeight: 600300, seen: 300 }, + 'bitcoincash:qaddr-bob': { addr: 'bitcoincash:qaddr-bob', blockHeight: 600300, seen: 200 }, + 'bitcoincash:qaddr-erin': { addr: 'bitcoincash:qaddr-erin', blockHeight: 600300, seen: 200 }, + 'bitcoincash:qaddr-carol': { addr: 'bitcoincash:qaddr-carol', blockHeight: 600200, seen: 400 } + }) + stubProfiles({ + 'bitcoincash:qaddr-alice': { text: 'alice bio', txid: 'profile-alice' }, + 'bitcoincash:qaddr-bob': { text: 'bob bio', txid: 'profile-bob' }, + 'bitcoincash:qaddr-erin': { text: 'erin bio', txid: 'profile-erin' }, + 'bitcoincash:qaddr-carol': { text: 'carol bio', txid: 'profile-carol' } + }) + + const { profiles, total } = await uut.listRecentProfiles({ limit: 5, offset: 0 }) + + assert.equal(total, 4) + assert.deepEqual(profiles.map((p) => p.addr), [ + 'bitcoincash:qaddr-alice', + 'bitcoincash:qaddr-bob', + 'bitcoincash:qaddr-erin', + 'bitcoincash:qaddr-carol' + ]) }) - it('should use block height 0 when field is missing', async () => { - async function * mockIterator () { - yield ['addr1', { text: 'hi', txid: 'tx1', seen: 1000 }] - } - profilesDb.iterator.returns(mockIterator()) + it('should report the recency block height and seen, not the profile record values', async () => { + stubRecency({ + 'bitcoincash:qaddr-alice': { addr: 'bitcoincash:qaddr-alice', blockHeight: 600300, seen: 300 } + }) + stubProfiles({ + 'bitcoincash:qaddr-alice': { text: 'alice bio', txid: 'profile-alice', blockHeight: 600010, seen: 10 } + }) - const result = await uut.scanProfilesWithBlockHeight() + const { profiles } = await uut.listRecentProfiles({ limit: 5, offset: 0 }) - assert.equal(result[0].blockHeight, 0) + assert.deepEqual(profiles[0], { + addr: 'bitcoincash:qaddr-alice', + text: 'alice bio', + txid: 'profile-alice', + blockHeight: 600300, + seen: 300 + }) + }) + + it('should paginate the ordered recency entries', async () => { + stubRecency({ + 'bitcoincash:qaddr-a': { addr: 'bitcoincash:qaddr-a', blockHeight: 600300, seen: 3 }, + 'bitcoincash:qaddr-b': { addr: 'bitcoincash:qaddr-b', blockHeight: 600200, seen: 2 }, + 'bitcoincash:qaddr-c': { addr: 'bitcoincash:qaddr-c', blockHeight: 600100, seen: 1 } + }) + stubProfiles({ + 'bitcoincash:qaddr-a': { text: 'a bio', txid: 'profile-a' }, + 'bitcoincash:qaddr-b': { text: 'b bio', txid: 'profile-b' }, + 'bitcoincash:qaddr-c': { text: 'c bio', txid: 'profile-c' } + }) + + const { profiles, total } = await uut.listRecentProfiles({ limit: 1, offset: 1 }) + + assert.equal(total, 3) + assert.deepEqual(profiles.map((p) => p.addr), ['bitcoincash:qaddr-b']) + }) + + it('should omit a recency record whose profile is missing', async () => { + stubRecency({ + 'bitcoincash:qaddr-a': { addr: 'bitcoincash:qaddr-a', blockHeight: 600300, seen: 3 }, + 'bitcoincash:qaddr-gone': { addr: 'bitcoincash:qaddr-gone', blockHeight: 600200, seen: 2 } + }) + stubProfiles({ + 'bitcoincash:qaddr-a': { text: 'a bio', txid: 'profile-a' } + }) + + const { profiles } = await uut.listRecentProfiles({ limit: 5, offset: 0 }) + + assert.deepEqual(profiles.map((p) => p.addr), ['bitcoincash:qaddr-a']) + }) + + it('should return an empty page when no recency store is configured', async () => { + const uutWithoutRecency = new ProfileQuery({ profilesDb }) + + const { profiles, total } = await uutWithoutRecency.listRecentProfiles({ limit: 5, offset: 0 }) + + assert.deepEqual(profiles, []) + assert.equal(total, 0) }) it('should join the display name and avatar for an address', async () => { diff --git a/psf-memo-db/test/unit/lib/backfill-profile-recency.unit.js b/psf-memo-db/test/unit/lib/backfill-profile-recency.unit.js new file mode 100644 index 0000000..41961f8 --- /dev/null +++ b/psf-memo-db/test/unit/lib/backfill-profile-recency.unit.js @@ -0,0 +1,104 @@ +import { assert } from 'chai' +import { backfillProfileRecency, partsFromAddrPostHeightKey } from '../../../src/lib/backfill-profile-recency.js' +import { FakeDb } from '../../support/level-double.js' + +const ALICE = 'bitcoincash:qaddr-alice' +const BOB = 'bitcoincash:qaddr-bob' +const NOPOST = 'bitcoincash:qaddr-nopost' + +function pad (height) { + return String(height).padStart(12, '0') +} + +function addrPostHeightKey (addr, height, txid) { + return `${addr}:${pad(height)}:${txid}` +} + +function fixtureStores () { + const profilesDb = new FakeDb([ + [ALICE, { text: 'alice bio', txid: 'profile-alice' }], + [BOB, { text: 'bob bio', txid: 'profile-bob' }], + [NOPOST, { text: 'nopost bio', txid: 'profile-nopost' }] + ]) + + const postsDb = new FakeDb([ + ['post-a1', { addr: ALICE, seen: 100, blockHeight: 600100 }], + ['reply-a1', { addr: ALICE, seen: 150, blockHeight: 600300 }], + ['post-b1', { addr: BOB, seen: 200, blockHeight: 600400 }], + ['post-b2', { addr: BOB, seen: 250, blockHeight: 600500 }] + ]) + + const addrPostHeightsDb = new FakeDb([ + [addrPostHeightKey(ALICE, 600100, 'post-a1'), { txid: 'post-a1', addr: ALICE, blockHeight: 600100 }], + [addrPostHeightKey(ALICE, 600300, 'reply-a1'), { txid: 'reply-a1', addr: ALICE, blockHeight: 600300 }], + [addrPostHeightKey(ALICE, 600200, 'poll-a1'), { txid: 'poll-a1', addr: ALICE, blockHeight: 600200 }], + [addrPostHeightKey(BOB, 600400, 'post-b1'), { txid: 'post-b1', addr: BOB, blockHeight: 600400 }], + [addrPostHeightKey(BOB, 600500, 'post-b2'), { txid: 'post-b2', addr: BOB, blockHeight: 600500 }] + ]) + + const postParentsDb = new FakeDb([ + ['reply-a1', { txid: 'reply-a1', parentTxid: 'post-a1' }] + ]) + const pollsDb = new FakeDb([ + ['poll-a1', { txid: 'poll-a1' }] + ]) + const statusDb = new FakeDb([ + ['status', { chainBlockHeight: 600450 }] + ]) + const profileRecencyDb = new FakeDb() + + return { profilesDb, postsDb, addrPostHeightsDb, postParentsDb, pollsDb, statusDb, profileRecencyDb } +} + +describe('#backfillProfileRecency', () => { + it('should record each profile newest confirmed qualifying post', async () => { + const stores = fixtureStores() + + const result = await backfillProfileRecency(stores) + + assert.equal(result.profiles, 2) + assert.deepEqual(stores.profileRecencyDb.store.get(ALICE), { + addr: ALICE, blockHeight: 600100, seen: 100 + }) + assert.deepEqual(stores.profileRecencyDb.store.get(BOB), { + addr: BOB, blockHeight: 600400, seen: 200 + }) + }) + + it('should not record a profile with no qualifying post', async () => { + const stores = fixtureStores() + + await backfillProfileRecency(stores) + + assert.isFalse(stores.profileRecencyDb.store.has(NOPOST)) + }) + + it('should be idempotent', async () => { + const stores = fixtureStores() + + await backfillProfileRecency(stores) + const afterFirst = new Map(stores.profileRecencyDb.store) + await backfillProfileRecency(stores) + + assert.deepEqual(stores.profileRecencyDb.store, afterFirst) + }) + + it('should remove stale recency records for addresses that no longer qualify', async () => { + const stores = fixtureStores() + stores.profileRecencyDb.put(NOPOST, { addr: NOPOST, blockHeight: 600900, seen: 9 }) + + await backfillProfileRecency(stores) + + assert.isFalse(stores.profileRecencyDb.store.has(NOPOST)) + }) +}) + +describe('#partsFromAddrPostHeightKey', () => { + it('should recover the address, height, and txid from a key', () => { + assert.deepEqual(partsFromAddrPostHeightKey(addrPostHeightKey(ALICE, 600100, 'post-a1')), { + addr: ALICE, + blockHeight: 600100, + txid: 'post-a1' + }) + }) +}) diff --git a/psf-memo-db/test/unit/use-cases/list-recent-profiles.unit.js b/psf-memo-db/test/unit/use-cases/list-recent-profiles.unit.js index 4b19ae1..50c46c9 100644 --- a/psf-memo-db/test/unit/use-cases/list-recent-profiles.unit.js +++ b/psf-memo-db/test/unit/use-cases/list-recent-profiles.unit.js @@ -6,8 +6,7 @@ describe('#ListRecentProfiles', () => { let uut let sandbox - const mockProfiles = [ - { addr: 'addr-a', text: 'a', txid: 'tx-a', seen: 100, blockHeight: 600100 }, + const page = [ { addr: 'addr-b', text: 'b', txid: 'tx-b', seen: 200, blockHeight: 600200 }, { addr: 'addr-c', text: 'c', txid: 'tx-c', seen: 50, blockHeight: 600200 } ] @@ -17,7 +16,7 @@ describe('#ListRecentProfiles', () => { uut = new ListRecentProfiles({ adapters: { profileQuery: { - scanProfilesWithBlockHeight: sandbox.stub().resolves([...mockProfiles]), + listRecentProfiles: sandbox.stub().resolves({ profiles: [...page], total: 3 }), getProfileIdentity: sandbox.stub().resolves({ name: null, profilePicUrl: null }) } } @@ -26,7 +25,7 @@ describe('#ListRecentProfiles', () => { afterEach(() => sandbox.restore()) - it('should join each profile display name and avatar', async () => { + it('should join each returned profile display name and avatar', async () => { uut.adapters.profileQuery.getProfileIdentity.callsFake(async (addr) => { if (addr === 'addr-b') { return { name: 'Bob', profilePicUrl: 'https://example.com/bob.jpg' } @@ -39,44 +38,34 @@ describe('#ListRecentProfiles', () => { const bob = result.profiles.find((p) => p.addr === 'addr-b') assert.equal(bob.name, 'Bob') assert.equal(bob.profilePicUrl, 'https://example.com/bob.jpg') - const alice = result.profiles.find((p) => p.addr === 'addr-a') - assert.equal(alice.name, null) - assert.equal(alice.profilePicUrl, null) + const carol = result.profiles.find((p) => p.addr === 'addr-c') + assert.equal(carol.name, null) + assert.equal(carol.profilePicUrl, null) }) - it('should only join identities for the requested page', async () => { - await uut.execute({ limit: 1, offset: 1 }) + it('should only join identities for the profiles the adapter returns', async () => { + await uut.execute({ limit: 2, offset: 1 }) - assert.equal(uut.adapters.profileQuery.getProfileIdentity.callCount, 1) - assert.equal(uut.adapters.profileQuery.getProfileIdentity.firstCall.args[0], 'addr-c') + assert.equal(uut.adapters.profileQuery.getProfileIdentity.callCount, 2) + assert.equal(uut.adapters.profileQuery.getProfileIdentity.firstCall.args[0], 'addr-b') + assert.equal(uut.adapters.profileQuery.getProfileIdentity.secondCall.args[0], 'addr-c') }) - it('should not change pagination metadata when joining identities', async () => { - const result = await uut.execute({ limit: 2, offset: 0 }) + it('should preserve adapter order and pagination metadata when joining identities', async () => { + const result = await uut.execute({ limit: 2, offset: 1 }) - assert.equal(result.pagination.total, 3) - assert.equal(result.pagination.hasMore, true) - }) - - it('should return profiles sorted by block height descending', async () => { - const result = await uut.execute({ limit: 10, offset: 0 }) - - assert.equal(result.profiles.length, 3) - assert.equal(result.profiles[0].addr, 'addr-b') - assert.equal(result.profiles[1].addr, 'addr-c') - assert.equal(result.profiles[2].addr, 'addr-a') + assert.deepEqual(result.profiles.map((p) => p.addr), ['addr-b', 'addr-c']) assert.equal(result.pagination.total, 3) assert.equal(result.pagination.hasMore, false) }) - it('should paginate with limit and offset', async () => { - const result = await uut.execute({ limit: 1, offset: 1 }) + it('should pass limit and offset through to the adapter', async () => { + await uut.execute({ limit: 7, offset: 14 }) - assert.equal(result.profiles.length, 1) - assert.equal(result.profiles[0].addr, 'addr-c') - assert.equal(result.pagination.limit, 1) - assert.equal(result.pagination.offset, 1) - assert.equal(result.pagination.hasMore, true) + assert.deepEqual(uut.adapters.profileQuery.listRecentProfiles.firstCall.args[0], { + limit: 7, + offset: 14 + }) }) it('should default limit to 100 and offset to 0', async () => { @@ -86,22 +75,6 @@ describe('#ListRecentProfiles', () => { assert.equal(result.pagination.offset, 0) }) - it('should sort equal-height profiles by seen descending, falsy seen last', async () => { - // Same blockHeight so only the `seen` tie-break matters. The dataset mixes - // truthy and falsy (0) seen values; a broken comparator is observable here - // because the falsy profiles are not already in descending input order. - const ties = [ - { addr: 'addr-a', txid: 't-a', seen: 0, blockHeight: 700000 }, - { addr: 'addr-b', txid: 't-b', seen: 0, blockHeight: 700000 }, - { addr: 'addr-c', txid: 't-c', seen: 1, blockHeight: 700000 } - ] - uut.adapters.profileQuery.scanProfilesWithBlockHeight.resolves(ties) - - const result = await uut.execute({ limit: 10, offset: 0 }) - - assert.deepEqual(result.profiles.map((p) => p.addr), ['addr-c', 'addr-a', 'addr-b']) - }) - it('should reject limit over 100', async () => { try { await uut.execute({ limit: 101 }) diff --git a/psf-memo-db/util/profiles/backfill-profile-recency.js b/psf-memo-db/util/profiles/backfill-profile-recency.js new file mode 100644 index 0000000..29c8865 --- /dev/null +++ b/psf-memo-db/util/profiles/backfill-profile-recency.js @@ -0,0 +1,90 @@ +/* + Utility: build the profileRecency index for an existing psf-memo-db. + + New deployments write the index while indexing live posts, but existing + databases populated before the recent-profile-ordering feature need a + one-time backfill. + + Run from the psf-memo-db repo root on the host that owns the LevelDB files: + + node util/profiles/backfill-profile-recency.js + + The script is idempotent: re-running it produces the same index. Progress and + a summary are printed to stderr. + + WARNING: + - This script opens the LevelDB files directly. psf-memo-db must NOT be + running, or another process must not hold the database locks. + - Make a backup of leveldb/current before running on a production server: + cp -r leveldb/current leveldb/current-pre-profile-recency-backup +*/ + +import level from 'level' +import * as fs from 'fs' +import * as path from 'path' +import * as url from 'url' +import { backfillProfileRecency } from '../../src/lib/backfill-profile-recency.js' + +const __dirname = url.fileURLToPath(new URL('.', import.meta.url)) + +const DATA_DIR = process.env.PSF_MEMO_DB_DATA_DIR + ? path.resolve(process.env.PSF_MEMO_DB_DATA_DIR) + : path.resolve(__dirname, '../../leveldb/current') + +function requiredStorePath (dir, name) { + const storePath = path.join(dir, name) + if (!fs.existsSync(storePath)) { + throw new Error(`Required LevelDB store not found: ${storePath}. Set PSF_MEMO_DB_DATA_DIR to the directory containing the profiles store.`) + } + return storePath +} + +async function main () { + console.error(`Using LevelDB data directory: ${DATA_DIR}`) + + const profilesPath = requiredStorePath(DATA_DIR, 'profiles') + const postsPath = requiredStorePath(DATA_DIR, 'posts') + const addrPostHeightsPath = requiredStorePath(DATA_DIR, 'addrPostHeights') + const postParentsPath = requiredStorePath(DATA_DIR, 'postParents') + const pollsPath = requiredStorePath(DATA_DIR, 'polls') + const statusPath = requiredStorePath(DATA_DIR, 'status') + const profileRecencyPath = path.join(DATA_DIR, 'profileRecency') + + console.error('Opening LevelDB stores...') + const profilesDb = level(profilesPath, { valueEncoding: 'json' }) + const postsDb = level(postsPath, { valueEncoding: 'json' }) + const addrPostHeightsDb = level(addrPostHeightsPath, { valueEncoding: 'json' }) + const postParentsDb = level(postParentsPath, { valueEncoding: 'json' }) + const pollsDb = level(pollsPath, { valueEncoding: 'json' }) + const statusDb = level(statusPath, { valueEncoding: 'json' }) + const profileRecencyDb = level(profileRecencyPath, { valueEncoding: 'json', createIfMissing: true }) + + try { + console.error('Backfilling profileRecency from addrPostHeights...') + const summary = await backfillProfileRecency({ + profilesDb, + postsDb, + addrPostHeightsDb, + postParentsDb, + pollsDb, + statusDb, + profileRecencyDb + }) + + console.error('\nProfile recency backfill complete.') + console.error(` profiles with a qualifying post: ${summary.profiles}`) + } catch (err) { + console.error('\nProfile recency backfill failed:', err.message) + process.exitCode = 1 + } finally { + await profileRecencyDb.close().catch(() => {}) + await statusDb.close().catch(() => {}) + await pollsDb.close().catch(() => {}) + await postParentsDb.close().catch(() => {}) + await addrPostHeightsDb.close().catch(() => {}) + await postsDb.close().catch(() => {}) + await profilesDb.close().catch(() => {}) + } +} + +main() diff --git a/psf-memo-indexer/acceptance/lib/handlers.js b/psf-memo-indexer/acceptance/lib/handlers.js index 9fc5db4..33834aa 100644 --- a/psf-memo-indexer/acceptance/lib/handlers.js +++ b/psf-memo-indexer/acceptance/lib/handlers.js @@ -17,6 +17,7 @@ import { handleMute } from '../../src/use-cases/action-types/mute.js' import { handleFollow } from '../../src/use-cases/action-types/follow.js' import { handleTopicMessage } from '../../src/use-cases/action-types/topic-message.js' import { handleTopicFollow } from '../../src/use-cases/action-types/topic-follow.js' +import { handleSetProfile } from '../../src/use-cases/action-types/set-profile.js' import { topicRecencyKey } from '../../src/use-cases/action-types/helpers.js' import BackupDb from '../../src/use-cases/backup-db.js' @@ -43,6 +44,9 @@ function makeInMemoryDb () { store.delete(key) return { success: true } }, + async * iterator () { + for (const entry of store.entries()) yield entry + }, entries () { return Array.from(store.entries()) } @@ -101,6 +105,13 @@ async function createWorld () { const roomDb = makeInMemoryDb() const topicSummaryDb = makeInMemoryDb() const topicRecencyDb = makeInMemoryDb() + const profileDb = makeInMemoryDb() + const profileRecencyDb = makeInMemoryDb() + const status = { startBlockHeight: 0, syncedBlockHeight: 999999999, chainBlockHeight: 999999999 } + const statusDb = { + getStatus: async () => status, + updateStatus: async (next) => { Object.assign(status, next); return true } + } const adapters = { postDb: postsDb, @@ -119,6 +130,9 @@ async function createWorld () { roomDb, topicSummaryDb, topicRecencyDb, + profileDb, + profileRecencyDb, + statusDb, processErrorDb: makeInMemoryDb(), dbCtrl: { backupDb: async (height, epoch) => { @@ -147,6 +161,9 @@ async function createWorld () { roomsDb: roomDb, topicSummariesDb: topicSummaryDb, topicRecencyDb, + profileDb, + profileRecencyDb, + status, txidMap: new Map(), lastTxid: null, lastHeight: null, @@ -185,17 +202,23 @@ const handlers = [ }, { name: 'process a Memo post transaction', - pattern: /^the indexer processes a Memo post transaction (.+) from (.+) at block height (.+) with text "(.+)"$/, + pattern: /^the indexer processes a Memo post transaction (.+) from (.+) at block height (.+) with text "(.+)"(?: seen at (.+))?$/, async run (m, example, world) { const txid = resolveTxid(m[1], example, world) const addr = resolveParam(m[2], example) const height = parseInt(resolveParam(m[3], example), 10) const text = resolveParam(m[4], example) + const seen = m[5] ? parseInt(resolveParam(m[5], example), 10) : Date.now() world.lastTxid = txid world.lastHeight = height world.lastAddr = addr + // A processed post is confirmed, so raise the status tip when needed. + if (world.status) { + world.status.chainBlockHeight = Math.max(world.status.chainBlockHeight ?? 0, height) + } + const prefix = Buffer.from('6d02', 'hex') const message = Buffer.from(text, 'utf8') @@ -203,7 +226,7 @@ const handlers = [ adapters: world.adapters, txid, signerAddr: addr, - seen: Date.now(), + seen, blockHeight: height, decoded: { action: 'post', @@ -986,6 +1009,118 @@ const muteHandlers = [ throw new Error(`Expected topicRecency ${room} at ${height}, got ${JSON.stringify(record)}`) } } + }, + { + name: 'db instance with profiles and profileRecency stores', + pattern: /^a psf-memo-db instance with profiles and profileRecency stores$/, + async run () { + // World is already created with both stores. + } + }, + { + name: 'store a profile for an address', + pattern: /^the psf-memo-db stores a profile for (.+)$/, + async run (m, example, world) { + const addr = resolveParam(m[1], example) + await world.profileDb.update(addr, { + addr, + text: 'my bio', + txid: `profile-${addr}`, + blockHeight: 600000, + seen: 1 + }) + } + }, + { + name: 'transaction indexer sees a Memo post transaction', + pattern: /^the transaction indexer sees a Memo post transaction (.+) from (.+) at block height (.+) with text "(.+)"$/, + async run (m, example, world) { + const txid = resolveTxid(m[1], example, world) + const addr = resolveParam(m[2], example) + const height = parseInt(resolveParam(m[3], example), 10) + const text = resolveParam(m[4], example) + + world.lastTxid = txid + world.lastHeight = height + world.lastAddr = addr + + // Mempool processing predicts the next height, which is above the chain + // tip, so the post is unconfirmed. + if (world.status) { + world.status.chainBlockHeight = Math.min(world.status.chainBlockHeight ?? height, height - 1) + } + + const prefix = Buffer.from('6d02', 'hex') + await handlePost({ + adapters: world.adapters, + txid, + signerAddr: addr, + seen: Date.now(), + blockHeight: height, + decoded: { + action: 'post', + prefix, + pushDatas: [prefix, Buffer.from(text, 'utf8')] + } + }) + } + }, + { + name: 'process a set-profile transaction', + pattern: /^the indexer processes a set-profile transaction for (.+) with text "(.+)"$/, + async run (m, example, world) { + const addr = resolveParam(m[1], example) + const text = resolveParam(m[2], example) + const txid = deriveTxid(`profile-${addr}-${text}`) + const height = 600400 + + world.lastTxid = txid + world.lastHeight = height + world.lastAddr = addr + + const prefix = Buffer.from('6d05', 'hex') + await handleSetProfile({ + adapters: world.adapters, + txid, + signerAddr: addr, + seen: Date.now(), + blockHeight: height, + decoded: { + action: 'setProfile', + prefix, + pushDatas: [prefix, Buffer.from(text, 'utf8')] + } + }) + } + }, + { + name: 'profileRecency records addr at height seen', + pattern: /^the profileRecency store records (.+) at block height (.+) seen at (.+)$/, + async run (m, example, world) { + const addr = resolveParam(m[1], example) + const height = parseInt(resolveParam(m[2], example), 10) + const seen = parseInt(resolveParam(m[3], example), 10) + let record + try { + record = await world.profileRecencyDb.get(addr) + } catch (err) { + throw new Error(`No profileRecency record for ${addr}`) + } + if (record.blockHeight !== height || record.seen !== seen) { + throw new Error(`Expected profileRecency ${addr} at ${height} seen ${seen}, got ${JSON.stringify(record)}`) + } + } + }, + { + name: 'profileRecency has no record for addr', + pattern: /^the profileRecency store has no record for (.+)$/, + async run (m, example, world) { + const addr = resolveParam(m[1], example) + const record = await world.profileRecencyDb.get(addr).catch(() => null) + if (record) { + throw new Error(`Expected no profileRecency record for ${addr}, got ${JSON.stringify(record)}`) + } + } } ] diff --git a/psf-memo-indexer/src/adapters/adapters-index.js b/psf-memo-indexer/src/adapters/adapters-index.js index 2cbdc2f..abbbc1e 100644 --- a/psf-memo-indexer/src/adapters/adapters-index.js +++ b/psf-memo-indexer/src/adapters/adapters-index.js @@ -29,6 +29,7 @@ class Adapters { this.nameDb = createEntityDb('name', 'addr', 'nameData') this.profileDb = createEntityDb('profile', 'addr', 'profileData') this.profilePicDb = createEntityDb('profilepic', 'addr', 'profilePicData') + this.profileRecencyDb = createEntityDb('profilerecency', 'addr', 'profileRecencyData') this.followDb = createEntityDb('follow', 'key', 'followData') this.followeeHeightDb = createEntityDb('followeeheight', 'key', 'followeeHeightData') this.muteDb = createEntityDb('mute', 'key', 'muteData') diff --git a/psf-memo-indexer/src/use-cases/action-types/post.js b/psf-memo-indexer/src/use-cases/action-types/post.js index dcec6a3..a01fbd5 100644 --- a/psf-memo-indexer/src/use-cases/action-types/post.js +++ b/psf-memo-indexer/src/use-cases/action-types/post.js @@ -1,5 +1,6 @@ import { utf8FromPush, logProcessError, normalizeTwoPushMemoDatas, postHeightKey, addrPostHeightKey } from './helpers.js' import { MAX_POST_SIZE } from '../../lib/memo-codes.js' +import { recordProfileRecency } from './profile-recency.js' // Create a record only when it does not already exist (idempotent writes). async function createIfMissing (db, key, value) { @@ -35,8 +36,11 @@ export async function handlePost (ctx) { await createIfMissing(adapters.postDb, txid, postData) await createIfMissing(adapters.postHeightDb, heightKey, { txid, blockHeight }) await createIfMissing(adapters.addrPostHeightDb, addrHeightKey, { txid, addr: signerAddr, blockHeight }) -} -// mutate4javascript-manifest-begin -// {"version":1,"tested_at":"2026-08-28T04:25:56.092Z","module_hash":"0f471223ce221777762f87eb02cc7921f2db624ecb1e333a148f9c682c383330","functions":[{"id":"func/createIfMissing","name":"createIfMissing","line":5,"end_line":11,"hash":"d59cefaf87075a2bc41538961b609387e35393d4fbf02ecfc0633026bbfdca42"},{"id":"func/handlePost","name":"handlePost","line":13,"end_line":38,"hash":"928ea72312ac9d0b94dbe93adef0b775bc8df158177c1b0adbac8cafe2da76d8"}]} -// mutate4javascript-manifest-end + // Only top-level posts and topic messages qualify for profile recency. + // Replies reuse handlePost to store their post record but must not move the + // author's recency. + if (decoded.action === 'post' || decoded.action === 'topicMessage') { + await recordProfileRecency(adapters, signerAddr, blockHeight, seen) + } +} diff --git a/psf-memo-indexer/src/use-cases/action-types/profile-recency.js b/psf-memo-indexer/src/use-cases/action-types/profile-recency.js new file mode 100644 index 0000000..7b85485 --- /dev/null +++ b/psf-memo-indexer/src/use-cases/action-types/profile-recency.js @@ -0,0 +1,96 @@ +/* + Maintain the profileRecency index for Memo profile authors. + + profileRecency holds one record per profile address that has at least one + confirmed qualifying post, keyed by address, value `{ addr, blockHeight, + seen }`. It lets the read side list profiles by their most recent post + without scanning addrPostHeights. + + A qualifying post is a top-level post (0x6d02) or a topic message (0x6d0c). + Replying (0x6d03) and poll creation (0x6d10) do not qualify. Recency is only + written for addresses that have a set-profile record (0x6d05), and only for + confirmed blocks (at or below status.chainBlockHeight). Processing posts in + any order converges on the greatest height, with seen as the tie-breaker, so + a replay never regresses a profile's recency. +*/ + +import { getIfPresent } from './helpers.js' + +// The chain tip bounds confirmation. With no status adapter, or a status +// without a numeric tip, return null so every post counts as confirmed. +async function getChainBlockHeight (adapters) { + const statusDb = adapters.statusDb + if (!statusDb) return null + try { + const status = await statusDb.getStatus() + return typeof status?.chainBlockHeight === 'number' ? status.chainBlockHeight : null + } catch (err) { + return null + } +} + +// A post is confirmed only when it is at or below the chain tip. A missing +// block height is an unconfirmed (mempool) post. +export function isConfirmed (blockHeight, chainBlockHeight) { + if (blockHeight === null || blockHeight === undefined) return false + if (chainBlockHeight === null || chainBlockHeight === undefined) return true + return blockHeight <= chainBlockHeight +} + +// Upsert a profile's recency, keeping the newest height and the newest seen at +// that height. Equal height and seen is a no-op, so reprocessing is idempotent. +async function upsertProfileRecency (adapters, addr, blockHeight, seen) { + const existing = await getIfPresent(adapters.profileRecencyDb, addr) + if (existing) { + const existingHeight = existing.blockHeight ?? 0 + const existingSeen = existing.seen ?? 0 + if (blockHeight < existingHeight) return existing + if (blockHeight === existingHeight && seen <= existingSeen) return existing + } + + const record = { addr, blockHeight, seen } + await adapters.profileRecencyDb.update(addr, record) + return record +} + +// Record a qualifying post for an author that already has a profile. The +// profile lookup keeps the index limited to addresses the recent list shows. +export async function recordProfileRecency (adapters, addr, blockHeight, seen) { + if (!adapters.profileRecencyDb || !adapters.profileDb) return null + if (!(await getIfPresent(adapters.profileDb, addr))) return null + if (!isConfirmed(blockHeight, await getChainBlockHeight(adapters))) return null + + return upsertProfileRecency(adapters, addr, blockHeight ?? 0, seen ?? 0) +} + +// Establish a profile's recency from the posts already indexed for its +// address. Runs when a set-profile action creates the profile, so a profile +// set after posting still reports the existing newest confirmed qualifying +// post. Replies and poll creations are excluded and unconfirmed posts are +// ignored. +export async function establishProfileRecency (adapters, addr) { + if (!adapters.profileRecencyDb || !adapters.addrPostHeightDb) return null + + const chainBlockHeight = await getChainBlockHeight(adapters) + const range = { gte: `${addr}:`, lte: `${addr}:\uffff` } + let best = null + + for await (const [key, value] of adapters.addrPostHeightDb.iterator(range)) { + if (!String(key).startsWith(`${addr}:`)) continue + const txid = value?.txid + const blockHeight = value?.blockHeight ?? 0 + if (!txid) continue + if (!isConfirmed(blockHeight, chainBlockHeight)) continue + if (await getIfPresent(adapters.postParentDb, txid)) continue + if (await getIfPresent(adapters.pollDb, txid)) continue + + const post = await getIfPresent(adapters.postDb, txid) + const seen = post?.seen ?? 0 + if (!best || blockHeight > best.blockHeight || (blockHeight === best.blockHeight && seen > best.seen)) { + best = { blockHeight, seen } + } + } + + if (!best) return null + return upsertProfileRecency(adapters, addr, best.blockHeight, best.seen) +} diff --git a/psf-memo-indexer/src/use-cases/action-types/set-profile.js b/psf-memo-indexer/src/use-cases/action-types/set-profile.js index 3d03eb7..ecd3234 100644 --- a/psf-memo-indexer/src/use-cases/action-types/set-profile.js +++ b/psf-memo-indexer/src/use-cases/action-types/set-profile.js @@ -1,5 +1,6 @@ import { utf8FromPush, logProcessError, normalizeTwoPushMemoDatas } from './helpers.js' import { MAX_POST_SIZE } from '../../lib/memo-codes.js' +import { establishProfileRecency } from './profile-recency.js' export async function handleSetProfile (ctx) { const { adapters, txid, signerAddr, decoded, seen, blockHeight } = ctx @@ -17,4 +18,5 @@ export async function handleSetProfile (ctx) { } await adapters.profileDb.create(signerAddr, { text, txid, seen, addr: signerAddr, blockHeight }) + await establishProfileRecency(adapters, signerAddr) } diff --git a/psf-memo-indexer/test/support/memory-db.js b/psf-memo-indexer/test/support/memory-db.js index 546492a..8b22a46 100644 --- a/psf-memo-indexer/test/support/memory-db.js +++ b/psf-memo-indexer/test/support/memory-db.js @@ -27,6 +27,15 @@ export function makeMemoryDb () { async delete (key) { store.delete(key) return { success: true } + }, + async * iterator (opts = {}) { + let keys = [...store.keys()].sort() + if (opts.gte !== undefined) keys = keys.filter((key) => key >= opts.gte) + if (opts.lte !== undefined) keys = keys.filter((key) => key <= opts.lte) + const limit = opts.limit === undefined ? keys.length : opts.limit + for (const key of keys.slice(0, limit)) { + yield [key, store.get(key)] + } } } } diff --git a/psf-memo-indexer/test/unit/use-cases/action-types/post.unit.js b/psf-memo-indexer/test/unit/use-cases/action-types/post.unit.js index 0e9c311..b0fecee 100644 --- a/psf-memo-indexer/test/unit/use-cases/action-types/post.unit.js +++ b/psf-memo-indexer/test/unit/use-cases/action-types/post.unit.js @@ -83,6 +83,36 @@ describe('#handlePost', () => { assert.equal(addrPostHeightCreate.callCount, 0) }) + it('should record profile recency when the author has a profile', async () => { + const notFound = new Error('not found') + notFound.notFound = true + const profileRecencyUpdate = sinon.stub().resolves({ success: true }) + + const adapters = { + postDb: { create: sinon.stub().resolves({ success: true }), get: sinon.stub().rejects(notFound) }, + postHeightDb: { create: sinon.stub().resolves({ success: true }), get: sinon.stub().rejects(notFound) }, + addrPostHeightDb: { create: sinon.stub().resolves({ success: true }), get: sinon.stub().rejects(notFound) }, + profileDb: { get: sinon.stub().resolves({ addr: 'bitcoincash:qptest', text: 'bio' }) }, + profileRecencyDb: { get: sinon.stub().rejects(notFound), update: profileRecencyUpdate }, + processErrorDb: { create: sinon.stub() } + } + + await handlePost({ + adapters, + txid: 'abc123', + signerAddr: 'bitcoincash:qptest', + seen: 1000, + blockHeight: 600100, + decoded: { action: 'post', prefix: PREFIX_POST, pushDatas: [PREFIX_POST, Buffer.from('hello memo')] } + }) + + assert.equal(profileRecencyUpdate.callCount, 1) + assert.deepEqual(profileRecencyUpdate.firstCall.args, [ + 'bitcoincash:qptest', + { addr: 'bitcoincash:qptest', blockHeight: 600100, seen: 1000 } + ]) + }) + it('should accept a post whose text is exactly at the maximum size', async () => { const create = sinon.stub().resolves({ success: true }) const get = sinon.stub().rejects(new Error('not found')) diff --git a/psf-memo-indexer/test/unit/use-cases/action-types/profile-recency.unit.js b/psf-memo-indexer/test/unit/use-cases/action-types/profile-recency.unit.js new file mode 100644 index 0000000..437e151 --- /dev/null +++ b/psf-memo-indexer/test/unit/use-cases/action-types/profile-recency.unit.js @@ -0,0 +1,135 @@ +import { assert } from 'chai' +import { makeMemoryDb } from '../../../support/memory-db.js' +import { + recordProfileRecency, + establishProfileRecency +} from '../../../../src/use-cases/action-types/profile-recency.js' + +function makeAdapters (overrides = {}) { + return { + profileDb: makeMemoryDb(), + profileRecencyDb: makeMemoryDb(), + addrPostHeightDb: makeMemoryDb(), + postParentDb: makeMemoryDb(), + pollDb: makeMemoryDb(), + postDb: makeMemoryDb(), + ...overrides + } +} + +describe('#recordProfileRecency', () => { + it('should record the recency for an author with a profile', async () => { + const adapters = makeAdapters() + await adapters.profileDb.update('bitcoincash:qaddr-a', { addr: 'bitcoincash:qaddr-a', text: 'bio' }) + + await recordProfileRecency(adapters, 'bitcoincash:qaddr-a', 600100, 1000) + + assert.deepEqual(adapters.profileRecencyDb.store.get('bitcoincash:qaddr-a'), { + addr: 'bitcoincash:qaddr-a', + blockHeight: 600100, + seen: 1000 + }) + }) + + it('should not record when the author has no profile', async () => { + const adapters = makeAdapters() + + await recordProfileRecency(adapters, 'bitcoincash:qaddr-a', 600100, 1000) + + assert.equal(adapters.profileRecencyDb.store.size, 0) + }) + + it('should not record an unconfirmed post', async () => { + const adapters = makeAdapters({ + statusDb: { getStatus: async () => ({ chainBlockHeight: 600050 }) } + }) + await adapters.profileDb.update('bitcoincash:qaddr-a', { addr: 'bitcoincash:qaddr-a', text: 'bio' }) + + await recordProfileRecency(adapters, 'bitcoincash:qaddr-a', 600100, 1000) + + assert.equal(adapters.profileRecencyDb.store.size, 0) + }) + + it('should keep the greatest height regardless of processing order', async () => { + const adapters = makeAdapters() + await adapters.profileDb.update('bitcoincash:qaddr-a', { addr: 'bitcoincash:qaddr-a', text: 'bio' }) + + await recordProfileRecency(adapters, 'bitcoincash:qaddr-a', 600200, 100) + await recordProfileRecency(adapters, 'bitcoincash:qaddr-a', 600100, 200) + + assert.equal(adapters.profileRecencyDb.store.get('bitcoincash:qaddr-a').blockHeight, 600200) + assert.equal(adapters.profileRecencyDb.store.get('bitcoincash:qaddr-a').seen, 100) + }) + + it('should keep the greatest seen at an equal height', async () => { + const adapters = makeAdapters() + await adapters.profileDb.update('bitcoincash:qaddr-a', { addr: 'bitcoincash:qaddr-a', text: 'bio' }) + + await recordProfileRecency(adapters, 'bitcoincash:qaddr-a', 600200, 100) + await recordProfileRecency(adapters, 'bitcoincash:qaddr-a', 600200, 300) + + assert.equal(adapters.profileRecencyDb.store.get('bitcoincash:qaddr-a').seen, 300) + }) + + it('should be idempotent for the same post', async () => { + const adapters = makeAdapters() + await adapters.profileDb.update('bitcoincash:qaddr-a', { addr: 'bitcoincash:qaddr-a', text: 'bio' }) + + await recordProfileRecency(adapters, 'bitcoincash:qaddr-a', 600100, 100) + await recordProfileRecency(adapters, 'bitcoincash:qaddr-a', 600100, 100) + + assert.deepEqual(adapters.profileRecencyDb.store.get('bitcoincash:qaddr-a'), { + addr: 'bitcoincash:qaddr-a', + blockHeight: 600100, + seen: 100 + }) + }) +}) + +describe('#establishProfileRecency', () => { + it('should pick the newest qualifying post and exclude replies and polls', async () => { + const adapters = makeAdapters() + await adapters.addrPostHeightDb.update('bitcoincash:qaddr-a:000000600100:post-a1', { + txid: 'post-a1', addr: 'bitcoincash:qaddr-a', blockHeight: 600100 + }) + await adapters.addrPostHeightDb.update('bitcoincash:qaddr-a:000000600200:poll-a1', { + txid: 'poll-a1', addr: 'bitcoincash:qaddr-a', blockHeight: 600200 + }) + await adapters.addrPostHeightDb.update('bitcoincash:qaddr-a:000000600300:reply-a1', { + txid: 'reply-a1', addr: 'bitcoincash:qaddr-a', blockHeight: 600300 + }) + await adapters.postDb.update('post-a1', { addr: 'bitcoincash:qaddr-a', seen: 100, blockHeight: 600100 }) + await adapters.postParentDb.update('reply-a1', { txid: 'reply-a1', parentTxid: 'post-a1' }) + await adapters.pollDb.update('poll-a1', { txid: 'poll-a1' }) + + await establishProfileRecency(adapters, 'bitcoincash:qaddr-a') + + assert.deepEqual(adapters.profileRecencyDb.store.get('bitcoincash:qaddr-a'), { + addr: 'bitcoincash:qaddr-a', + blockHeight: 600100, + seen: 100 + }) + }) + + it('should ignore an unconfirmed qualifying post', async () => { + const adapters = makeAdapters({ + statusDb: { getStatus: async () => ({ chainBlockHeight: 600450 }) } + }) + await adapters.addrPostHeightDb.update('bitcoincash:qaddr-b:000000600500:post-b2', { + txid: 'post-b2', addr: 'bitcoincash:qaddr-b', blockHeight: 600500 + }) + await adapters.postDb.update('post-b2', { addr: 'bitcoincash:qaddr-b', seen: 250, blockHeight: 600500 }) + + await establishProfileRecency(adapters, 'bitcoincash:qaddr-b') + + assert.equal(adapters.profileRecencyDb.store.size, 0) + }) + + it('should not record a profile with no qualifying post', async () => { + const adapters = makeAdapters() + + await establishProfileRecency(adapters, 'bitcoincash:qaddr-nopost') + + assert.equal(adapters.profileRecencyDb.store.size, 0) + }) +}) diff --git a/psf-memo-indexer/test/unit/use-cases/action-types/set-profile.unit.js b/psf-memo-indexer/test/unit/use-cases/action-types/set-profile.unit.js new file mode 100644 index 0000000..cf6722f --- /dev/null +++ b/psf-memo-indexer/test/unit/use-cases/action-types/set-profile.unit.js @@ -0,0 +1,69 @@ +import { assert } from 'chai' +import { makeMemoryDb } from '../../../support/memory-db.js' +import { handleSetProfile } from '../../../../src/use-cases/action-types/set-profile.js' + +const PREFIX_SET_PROFILE = Buffer.from('6d05', 'hex') + +function makeAdapters () { + return { + profileDb: makeMemoryDb(), + profileRecencyDb: makeMemoryDb(), + addrPostHeightDb: makeMemoryDb(), + postDb: makeMemoryDb(), + postParentDb: makeMemoryDb(), + pollDb: makeMemoryDb(), + processErrorDb: makeMemoryDb() + } +} + +describe('#handleSetProfile', () => { + it('should establish recency from the newest existing qualifying post', async () => { + const adapters = makeAdapters() + await adapters.addrPostHeightDb.update('bitcoincash:qaddr-a:000000600100:post-a1', { + txid: 'post-a1', addr: 'bitcoincash:qaddr-a', blockHeight: 600100 + }) + await adapters.addrPostHeightDb.update('bitcoincash:qaddr-a:000000600300:reply-a1', { + txid: 'reply-a1', addr: 'bitcoincash:qaddr-a', blockHeight: 600300 + }) + await adapters.postDb.update('post-a1', { addr: 'bitcoincash:qaddr-a', seen: 100, blockHeight: 600100 }) + await adapters.postParentDb.update('reply-a1', { txid: 'reply-a1', parentTxid: 'post-a1' }) + + await handleSetProfile({ + adapters, + txid: 'profile-a1', + signerAddr: 'bitcoincash:qaddr-a', + seen: 500, + blockHeight: 600400, + decoded: { + action: 'setProfile', + prefix: PREFIX_SET_PROFILE, + pushDatas: [PREFIX_SET_PROFILE, Buffer.from('my bio')] + } + }) + + assert.deepEqual(adapters.profileRecencyDb.store.get('bitcoincash:qaddr-a'), { + addr: 'bitcoincash:qaddr-a', + blockHeight: 600100, + seen: 100 + }) + }) + + it('should not create a recency record when the address has no qualifying post', async () => { + const adapters = makeAdapters() + + await handleSetProfile({ + adapters, + txid: 'profile-a1', + signerAddr: 'bitcoincash:qaddr-a', + seen: 500, + blockHeight: 600400, + decoded: { + action: 'setProfile', + prefix: PREFIX_SET_PROFILE, + pushDatas: [PREFIX_SET_PROFILE, Buffer.from('my bio')] + } + }) + + assert.equal(adapters.profileRecencyDb.store.size, 0) + }) +})