mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo.git
synced 2026-09-21 16:52:01 -07:00
Implement profile ordering by last post
Indexer: maintain a profileRecency index keyed by address with the newest confirmed qualifying post (top-level 0x6d02 or topic message 0x6d0c). Replies and poll creations do not qualify, and mempool (unconfirmed) posts do not write the index. Setting a profile after posting establishes recency from the address's existing addrPostHeights, excluding replies and polls. DB: GET /profile/recent now orders profiles by their most recent qualifying post (height desc, seen desc, address asc) from profileRecency plus per-page profile lookups, so it no longer scans addrPostHeights or sorts the whole profiles store. Add a profileRecency backfill library and CLI that rebuilds the index from addrPostHeights, posts, postParents, polls, and status, ignoring unconfirmed entries and removing stale records. Add focused unit tests, acceptance fixtures, and regex step handlers. By coder.
This commit is contained in:
@@ -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)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -21,6 +21,7 @@ const DB_NAMES = [
|
||||
'names',
|
||||
'profiles',
|
||||
'profilePics',
|
||||
'profileRecency',
|
||||
'follows',
|
||||
'followeeHeights',
|
||||
'mutes',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
|
||||
@@ -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()
|
||||
@@ -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)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user