Harden profile-last-post with mutation-killing tests

Add unit tests for the profileRecency backfill and recent-profiles read
path that kill the language-mutation survivors: status-tip confirmation
(no tip, exact tip), exclusion of addresses without a profile, replies,
and polls, malformed addrPostHeight entries, missing optional stores,
seen/height defaulting, stale-record removal, and the height/seen/address
comparator tie-breakers. Tighten the indexer recency tests the same way
and assert the set-profile text and max-size boundary.

Refresh the mutate4javascript manifests and write the gherkin-mutator
acceptance mutation metadata for the changed features.

By architect.
This commit is contained in:
Chris Troutner
2026-09-20 15:29:24 -07:00
parent 6b8da07981
commit ace703831f
18 changed files with 414 additions and 62 deletions
@@ -1,3 +1,8 @@
# mutation-stamp: sha256=010763f046b5bdd82c95d9992d35c34fa82155245f35148808d945fcef2a7d72
# acceptance-mutation-manifest-begin
# {"version":1,"tested_at":"2026-09-20T22:26:06.447781649Z","feature_name":"Backfill profile recency","feature_path":"/home/trout/work/psf-memo/.worktrees/architect/psf-memo-db/specs/backfill-profile-recency.feature","background_hash":"9f29f1fd1af8398c20a9345845798f4d950d7074d138b4acd70ea111b8cf1017","implementation_hash":"unknown","scenarios":[{"index":0,"name":"Backfill profile recency - 1 backfill records each profile's newest confirmed qualifying post","scenario_hash":"7926ea450d3754a5bfe456b5c8e61648d7ac7d1ada17288cb4c97b937e9c127a","mutation_count":6,"result":{"Total":6,"Killed":6,"Survived":0,"Errors":0},"tested_at":"2026-09-20T22:26:06.447781649Z"},{"index":2,"name":"Backfill profile recency - 3 backfill is idempotent","scenario_hash":"594c743f466022e0ff0008efce7f2134cfe2c12845c8da4eaebab2da510c4f6a","mutation_count":6,"result":{"Total":6,"Killed":6,"Survived":0,"Errors":0},"tested_at":"2026-09-20T22:26:06.447781649Z"}]}
# acceptance-mutation-manifest-end
# Scenarios: Backfill profile recency - 1, Backfill profile recency - 2, Backfill profile recency - 3
#
# The profile recency backfill builds the profileRecency store from existing
@@ -1,6 +1,6 @@
# mutation-stamp: sha256=f2672d16093128ab98f47d9bd32fb777fe6a860143e3a04492a6a8446314742b
# mutation-stamp: sha256=a9cdc032be8bad4a0ec047df3de8eb08b8ef47e305626e5e0f979d84f159d257
# acceptance-mutation-manifest-begin
# {"version":1,"tested_at":"2026-09-20T20:08:42.739895979Z","feature_name":"Recent Profile Identity","feature_path":"/home/trout/work/psf-memo/.worktrees/architect/psf-memo-db/specs/recent-profile-identity.feature","background_hash":"0289c6ddd1a0f1e7e23830863aec6d44f90f314d174423c44e8567da4e18f645","implementation_hash":"unknown","scenarios":[{"index":0,"name":"Recent Profile Identity - 1 GET /profile/recent returns each profile's display name and avatar","scenario_hash":"ed4ba1b33ac244e193e9fb24bfd845d351f00cabe5bd507dfe735d73055ecece","mutation_count":9,"result":{"Total":9,"Killed":9,"Survived":0,"Errors":0},"tested_at":"2026-09-20T20:08:42.739895979Z"}]}
# {"version":1,"tested_at":"2026-09-20T22:26:26.477694996Z","feature_name":"Recent Profile Identity","feature_path":"/home/trout/work/psf-memo/.worktrees/architect/psf-memo-db/specs/recent-profile-identity.feature","background_hash":"f3df644832c7ba4bfe29da14ebe7c26c7ebc3d0e15db0b39854f6b69c593b670","implementation_hash":"unknown","scenarios":[{"index":0,"name":"Recent Profile Identity - 1 GET /profile/recent returns each profile's display name and avatar","scenario_hash":"ed4ba1b33ac244e193e9fb24bfd845d351f00cabe5bd507dfe735d73055ecece","mutation_count":9,"result":{"Total":9,"Killed":9,"Survived":0,"Errors":0},"tested_at":"2026-09-20T22:26:26.477694996Z"}]}
# acceptance-mutation-manifest-end
# Scenarios: Recent Profile Identity - 1
@@ -1,3 +1,7 @@
# acceptance-mutation-manifest-begin
# {"version":1,"tested_at":"2026-09-20T22:25:45.967266375Z","feature_name":"Recent Profile Ordering","feature_path":"/home/trout/work/psf-memo/.worktrees/architect/psf-memo-db/specs/recent-profile-ordering.feature","background_hash":"8646bb1712dba3493a9703bf3fb32c011f7d4c1d7d9ed749868f9d9b247cdcd2","implementation_hash":"unknown","scenarios":[{"index":1,"name":"Recent Profile Ordering - 2 each row reports the most recent post's block and seen","scenario_hash":"bbbb3e8a87dab0a3380f879cbb511728cf980ad52e46f6d3f411666a4d3b8d87","mutation_count":9,"result":{"Total":9,"Killed":9,"Survived":0,"Errors":0},"tested_at":"2026-09-20T22:25:45.967266375Z"}]}
# acceptance-mutation-manifest-end
# Scenarios: Recent Profile Ordering - 1, Recent Profile Ordering - 2, Recent Profile Ordering - 3, Recent Profile Ordering - 4
#
# GET /profile/recent lists one row per profile address that has at least one
+1 -1
View File
@@ -94,5 +94,5 @@ class Adapters {
export default Adapters
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-09-20T20:07:23.167Z","module_hash":"2f12ab15e729d2f35a9d092fd387e336a8ea21caa5c48c04e153b5d0f707947d","functions":[{"id":"func/Adapters.constructor","name":"Adapters.constructor","line":17,"end_line":22,"hash":"d5d1fd454ddfa112378f278773b65be5cb39aab75473dd57c04c9a385de1aa58"},{"id":"func/Adapters.openDatabases","name":"Adapters.openDatabases","line":24,"end_line":84,"hash":"6fe3d5ece4a1783936aa3c8f3fdd782206ae0bc417a06a048b1416a0dce8fb75"},{"id":"func/Adapters.start","name":"Adapters.start","line":86,"end_line":90,"hash":"f9e62a9199f0259f5c22913625e6e497548b3887cc24871bba475e73e52b3750"}]}
// {"version":1,"tested_at":"2026-09-20T21:56:16.308Z","module_hash":"7e2f08e8c4f5cbe9c883e29212a6391cd63a20f0875c29c192a0f078e1363458","functions":[{"id":"func/Adapters.constructor","name":"Adapters.constructor","line":17,"end_line":22,"hash":"d5d1fd454ddfa112378f278773b65be5cb39aab75473dd57c04c9a385de1aa58"},{"id":"func/Adapters.openDatabases","name":"Adapters.openDatabases","line":24,"end_line":85,"hash":"ed9027f054515a862e5eeaaaf5a996975cad3c910f2cd4e486c369a0a0282fa6"},{"id":"func/Adapters.start","name":"Adapters.start","line":87,"end_line":91,"hash":"f9e62a9199f0259f5c22913625e6e497548b3887cc24871bba475e73e52b3750"}]}
// mutate4javascript-manifest-end
+1 -1
View File
@@ -84,5 +84,5 @@ export { DB_NAMES, dbDir }
export default LevelDb
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-09-18T17:41:14.431Z","module_hash":"33eff30104aa55b918a55cbf874668dbc0c8f42d7bc4aced4ec1348917a73006","functions":[{"id":"func/LevelDb.constructor","name":"LevelDb.constructor","line":38,"end_line":45,"hash":"1c866e2484a08aa32503fb55bdfc2d711d2aedc0410e6fe749be66ec99e0f400"},{"id":"func/LevelDb.openDbs","name":"LevelDb.openDbs","line":47,"end_line":61,"hash":"1de94c7f46c342506f27b0066597f827bf1a57fa2caf2e1b6587701ae0e3c32f"},{"id":"func/LevelDb.getDbList","name":"LevelDb.getDbList","line":63,"end_line":65,"hash":"5a150ab18ab1e71f5a626c8fdd6f9dbe5d9a2406ad720f1875df32940de20976"},{"id":"func/LevelDb.closeDbs","name":"LevelDb.closeDbs","line":67,"end_line":72,"hash":"de21953bce0228ed9fffef541dc8326fe3cec9f8b3c85afd75eeb4671c15effe"},{"id":"func/LevelDb.ensureDirectories","name":"LevelDb.ensureDirectories","line":74,"end_line":79,"hash":"9ecaa7313531e06fab4148077786152467b83c19dee074a6218e8e43f4436b75"}]}
// {"version":1,"tested_at":"2026-09-20T21:57:39.141Z","module_hash":"5416df84f1fef398fd6ddd886f4467c5421e331aaa4299599cbfda271d2d7d7b","functions":[{"id":"func/LevelDb.constructor","name":"LevelDb.constructor","line":39,"end_line":46,"hash":"1c866e2484a08aa32503fb55bdfc2d711d2aedc0410e6fe749be66ec99e0f400"},{"id":"func/LevelDb.openDbs","name":"LevelDb.openDbs","line":48,"end_line":62,"hash":"1de94c7f46c342506f27b0066597f827bf1a57fa2caf2e1b6587701ae0e3c32f"},{"id":"func/LevelDb.getDbList","name":"LevelDb.getDbList","line":64,"end_line":66,"hash":"5a150ab18ab1e71f5a626c8fdd6f9dbe5d9a2406ad720f1875df32940de20976"},{"id":"func/LevelDb.closeDbs","name":"LevelDb.closeDbs","line":68,"end_line":73,"hash":"de21953bce0228ed9fffef541dc8326fe3cec9f8b3c85afd75eeb4671c15effe"},{"id":"func/LevelDb.ensureDirectories","name":"LevelDb.ensureDirectories","line":75,"end_line":80,"hash":"9ecaa7313531e06fab4148077786152467b83c19dee074a6218e8e43f4436b75"}]}
// mutate4javascript-manifest-end
@@ -112,3 +112,7 @@ class ProfileQuery {
}
export default ProfileQuery
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-09-20T22:17:12.092Z","module_hash":"cd4a1178c4968e0a61acab324514fb4160e41e98535acd134fab994453484fcc","functions":[{"id":"func/ProfileQuery.constructor","name":"ProfileQuery.constructor","line":19,"end_line":32,"hash":"3ac4ed8e2d002256a3fcec017bc8ae0e783f29fe1de9e8b0eac04852b37ecc42"},{"id":"func/ProfileQuery.compareRecency","name":"ProfileQuery.compareRecency","line":37,"end_line":42,"hash":"891628bf31dbb0cf5d9a8560d9243328675f1f2278077eddc7d3d44836027b70"},{"id":"func/ProfileQuery.listRecencyEntries","name":"ProfileQuery.listRecencyEntries","line":46,"end_line":59,"hash":"618f0ae6b9c14a9fadfa6ffe97e818e81d14ca9c1c22fcc98a4d1f6c62444135"},{"id":"func/ProfileQuery.listRecentProfiles","name":"ProfileQuery.listRecentProfiles","line":64,"end_line":85,"hash":"a0c84485d1149689550216c16fda0c5312f6077118d306204b3837479d2cc978"},{"id":"func/ProfileQuery.getProfileIdentity","name":"ProfileQuery.getProfileIdentity","line":91,"end_line":101,"hash":"66105f1c77dadc97f32beec8c952184617e07a0401eae1af1128de70f47df070"},{"id":"func/ProfileQuery.getRecordOrNull","name":"ProfileQuery.getRecordOrNull","line":103,"end_line":111,"hash":"5604da1e9adc18abda5e58e4f366aa0bcc8e0dd417e2f6882c27289df7fb7a14"}]}
// mutate4javascript-manifest-end
@@ -59,5 +59,5 @@ export const ENTITY_CONFIG = [
]
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-09-18T17:40:30.095Z","module_hash":"d37a0c9ebf7d94a345a2b129ffccaabb16ee06906c8f029efd2d7a4a8243936c","functions":[{"id":"func/makeCrudHandlers","name":"makeCrudHandlers","line":5,"end_line":35,"hash":"133b135aa115c058291b6df4a0b6b15152dde83c3163890e0eab0c3b018f8d4d"}]}
// {"version":1,"tested_at":"2026-09-20T21:59:36.940Z","module_hash":"471a9db88c0e95824a435ad338cd45277ac3dcd439175cd7fa084b87b227dd29","functions":[{"id":"func/makeCrudHandlers","name":"makeCrudHandlers","line":5,"end_line":35,"hash":"133b135aa115c058291b6df4a0b6b15152dde83c3163890e0eab0c3b018f8d4d"}]}
// mutate4javascript-manifest-end
@@ -139,3 +139,7 @@ export async function backfillProfileRecency (stores) {
await removeStaleRecency(stores.profileRecencyDb, new Set(best.keys()))
return { profiles: best.size }
}
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-09-20T22:14:18.244Z","module_hash":"c6da57d385443b5883d925cdce874fb67ebbc1d4eecd78446dd992b538d7ce6c","functions":[{"id":"func/isNotFound","name":"isNotFound","line":21,"end_line":23,"hash":"3a97ac721c26cf8c2e1edae456afe59263be988cee566a87758f2cb013d8691c"},{"id":"func/getRecord","name":"getRecord","line":26,"end_line":34,"hash":"948bd0435cdce8acf433dbe3aebb9ede9929e6598610a888ec8443947e4cb5a8"},{"id":"func/readChainBlockHeight","name":"readChainBlockHeight","line":38,"end_line":41,"hash":"342991c6aa4dddf98ba8e8854f631a01a46d2f7bd4173ff78e9c601d75351f37"},{"id":"func/partsFromAddrPostHeightKey","name":"partsFromAddrPostHeightKey","line":46,"end_line":57,"hash":"1d48cbd481a5e21704ae4e352190c809b8620ee6c51c74eca4e41e2d25407afb"},{"id":"func/entryFields","name":"entryFields","line":61,"end_line":68,"hash":"a71641df77abbd4c553532b42f5fbf4cec4fd09ff809bc8b2264a1f91fcd050e"},{"id":"func/isConfirmedEntry","name":"isConfirmedEntry","line":72,"end_line":75,"hash":"e3d1fffa4b2ad3fcff3437d09f5ff1e9596ad921d60260db19385a2b3111fac2"},{"id":"func/isQualifyingPost","name":"isQualifyingPost","line":79,"end_line":84,"hash":"d58b96768f927594592576fde274d9a5f69d54df1b73184ecc38a5735ca5e252"},{"id":"func/qualifyingCandidate","name":"qualifyingCandidate","line":88,"end_line":95,"hash":"ec80fdecd67123dc9cb95f351933e6777b44b21b160cdc2d8e7cb0d8b9f9f072"},{"id":"func/isNewer","name":"isNewer","line":99,"end_line":103,"hash":"60db8ecaa7d252fb34515a16e4aa8388df6a4e2a0965425ec4c49df49cf0c32a"},{"id":"func/keepNewest","name":"keepNewest","line":107,"end_line":109,"hash":"17fd4a511b90bf952058a2ccf645b1f29fa68134aee1d4590bf7c375409fcfb3"},{"id":"func/collectBestRecency","name":"collectBestRecency","line":111,"end_line":118,"hash":"19618f9ad24d6b1740f1765d952a1b65cbc062319962051264193d24913349a1"},{"id":"func/writeRecency","name":"writeRecency","line":120,"end_line":124,"hash":"35ad5339afbd58f4df16ec5bc400b7a3e6e8136fc0d726dd6368a8be54d34b57"},{"id":"func/removeStaleRecency","name":"removeStaleRecency","line":126,"end_line":130,"hash":"494b04d530f09bc1ad5ff09c4b0b3cfe6a98366e4e4c21395f9df45bedb61f63"},{"id":"func/backfillProfileRecency","name":"backfillProfileRecency","line":135,"end_line":141,"hash":"a737e754befdc198a62ec6e6392e80ed4cde419019c9f04492212f971db42428"}]}
// mutate4javascript-manifest-end
@@ -36,3 +36,7 @@ class ListRecentProfiles extends ListUseCase {
}
export default ListRecentProfiles
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-09-20T21:53:30.844Z","module_hash":"ee68d093a6a1377f027641c165bf55ca0b8ca019a0f5449eb39c666b91b80bae","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":35,"hash":"a629483fb61bd14c2a27880b667290c1dfbf2fb4e55faf14fe1010b2532b93bb"}]}
// mutate4javascript-manifest-end
@@ -40,10 +40,11 @@ describe('#ProfileQuery', () => {
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 },
// Deliberately unsorted so the comparator must do the work.
'bitcoincash:qaddr-carol': { addr: 'bitcoincash:qaddr-carol', blockHeight: 600200, seen: 400 },
'bitcoincash:qaddr-erin': { addr: 'bitcoincash:qaddr-erin', blockHeight: 600300, seen: 200 },
'bitcoincash:qaddr-carol': { addr: 'bitcoincash:qaddr-carol', blockHeight: 600200, seen: 400 }
'bitcoincash:qaddr-alice': { addr: 'bitcoincash:qaddr-alice', blockHeight: 600300, seen: 300 },
'bitcoincash:qaddr-bob': { addr: 'bitcoincash:qaddr-bob', blockHeight: 600300, seen: 200 }
})
stubProfiles({
'bitcoincash:qaddr-alice': { text: 'alice bio', txid: 'profile-alice' },
@@ -63,6 +64,50 @@ describe('#ProfileQuery', () => {
])
})
it('compareRecency ranks greater height and greater seen before lower values', () => {
const high = { addr: 'bitcoincash:qaddr-a', blockHeight: 600200, seen: 1 }
const low = { addr: 'bitcoincash:qaddr-b', blockHeight: 600100, seen: 1 }
assert.isBelow(uut.compareRecency(high, low), 0)
assert.isAbove(uut.compareRecency(low, high), 0)
const newerSeen = { addr: 'bitcoincash:qaddr-c', blockHeight: 600200, seen: 5 }
const olderSeen = { addr: 'bitcoincash:qaddr-d', blockHeight: 600200, seen: 2 }
assert.isBelow(uut.compareRecency(newerSeen, olderSeen), 0)
assert.isAbove(uut.compareRecency(olderSeen, newerSeen), 0)
const same = { addr: 'bitcoincash:qaddr-e', blockHeight: 600200, seen: 5 }
assert.equal(uut.compareRecency(same, { ...same }), 0)
const addrA = { addr: 'bitcoincash:qaddr-a', blockHeight: 600200, seen: 5 }
const addrB = { addr: 'bitcoincash:qaddr-b', blockHeight: 600200, seen: 5 }
assert.isBelow(uut.compareRecency(addrA, addrB), 0)
assert.isAbove(uut.compareRecency(addrB, addrA), 0)
})
it('listRecencyEntries defaults a missing height and seen to 0', async () => {
stubRecency({ 'bitcoincash:qaddr-a': { addr: 'bitcoincash:qaddr-a' } })
const entries = await uut.listRecencyEntries()
assert.deepEqual(entries, [{ addr: 'bitcoincash:qaddr-a', blockHeight: 0, seen: 0 }])
})
it('should default limit to 100 and offset to 0', async () => {
stubRecency({
'bitcoincash:qaddr-a': { addr: 'bitcoincash:qaddr-a', blockHeight: 600300, seen: 3 },
'bitcoincash:qaddr-b': { addr: 'bitcoincash:qaddr-b', blockHeight: 600200, seen: 2 }
})
stubProfiles({
'bitcoincash:qaddr-a': { text: 'a bio', txid: 'profile-a' },
'bitcoincash:qaddr-b': { text: 'b bio', txid: 'profile-b' }
})
const { profiles, total } = await uut.listRecentProfiles()
assert.equal(total, 2)
assert.deepEqual(profiles.map((p) => p.addr), ['bitcoincash:qaddr-a', 'bitcoincash:qaddr-b'])
})
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 }
@@ -91,6 +91,116 @@ describe('#backfillProfileRecency', () => {
assert.isFalse(stores.profileRecencyDb.store.has(NOPOST))
})
it('should rethrow an unexpected store error instead of treating it as not found', async () => {
const stores = fixtureStores()
stores.profilesDb.get = async () => { throw new Error('store boom') }
let error
try {
await backfillProfileRecency(stores)
} catch (err) {
error = err
}
assert.equal(error?.message, 'store boom')
})
it('should treat every entry as confirmed when the status store has no chain tip', async () => {
const stores = fixtureStores()
stores.statusDb = new FakeDb()
await backfillProfileRecency(stores)
// With no tip, Bob's unconfirmed post-b2 at 600500 is the newest.
assert.deepEqual(stores.profileRecencyDb.store.get(BOB), {
addr: BOB, blockHeight: 600500, seen: 250
})
})
it('should treat a post exactly at the chain tip as confirmed', async () => {
const stores = fixtureStores()
stores.statusDb = new FakeDb([['status', { chainBlockHeight: 600500 }]])
await backfillProfileRecency(stores)
assert.deepEqual(stores.profileRecencyDb.store.get(BOB), {
addr: BOB, blockHeight: 600500, seen: 250
})
})
it('should not record a qualifying post from an address without a profile', async () => {
const stores = fixtureStores()
const noprofile = 'bitcoincash:qaddr-noprofile'
stores.addrPostHeightsDb.put(addrPostHeightKey(noprofile, 600100, 'post-np'), {
txid: 'post-np', addr: noprofile, blockHeight: 600100
})
stores.postsDb.put('post-np', { addr: noprofile, seen: 50, blockHeight: 600100 })
await backfillProfileRecency(stores)
assert.isFalse(stores.profileRecencyDb.store.has(noprofile))
})
it('should ignore an addrPostHeights entry with no txid', async () => {
const stores = fixtureStores()
stores.addrPostHeightsDb.put(addrPostHeightKey(ALICE, 600350, ''), null)
await backfillProfileRecency(stores)
// The malformed entry must not displace Alice's real post-a1.
assert.deepEqual(stores.profileRecencyDb.store.get(ALICE), {
addr: ALICE, blockHeight: 600100, seen: 100
})
})
it('should default the seen time to 0 when the post record is missing', async () => {
const stores = fixtureStores()
const ghost = 'bitcoincash:qaddr-ghost'
stores.profilesDb.put(ghost, { text: 'ghost bio', txid: 'profile-ghost' })
stores.addrPostHeightsDb.put(addrPostHeightKey(ghost, 600100, 'post-ghost'), {
txid: 'post-ghost', addr: ghost, blockHeight: 600100
})
await backfillProfileRecency(stores)
assert.deepEqual(stores.profileRecencyDb.store.get(ghost), {
addr: ghost, blockHeight: 600100, seen: 0
})
})
it('should tolerate missing optional reply/poll stores', async () => {
const stores = fixtureStores()
delete stores.postParentsDb
delete stores.pollsDb
await backfillProfileRecency(stores)
// Without the reply store, reply-a1 at 600300 becomes Alice's newest.
assert.deepEqual(stores.profileRecencyDb.store.get(ALICE), {
addr: ALICE, blockHeight: 600300, seen: 150
})
})
it('should pick the greater seen time for two posts at equal height', async () => {
const stores = fixtureStores()
const tie = 'bitcoincash:qaddr-tie'
stores.profilesDb.put(tie, { text: 'tie bio', txid: 'profile-tie' })
stores.addrPostHeightsDb.put(addrPostHeightKey(tie, 600100, 'post-t1'), {
txid: 'post-t1', addr: tie, blockHeight: 600100
})
stores.addrPostHeightsDb.put(addrPostHeightKey(tie, 600100, 'post-t2'), {
txid: 'post-t2', addr: tie, blockHeight: 600100
})
stores.postsDb.put('post-t1', { addr: tie, seen: 50, blockHeight: 600100 })
stores.postsDb.put('post-t2', { addr: tie, seen: 80, blockHeight: 600100 })
await backfillProfileRecency(stores)
assert.deepEqual(stores.profileRecencyDb.store.get(tie), {
addr: tie, blockHeight: 600100, seen: 80
})
})
})
describe('#partsFromAddrPostHeightKey', () => {
@@ -101,4 +211,12 @@ describe('#partsFromAddrPostHeightKey', () => {
txid: 'post-a1'
})
})
it('should default a non-numeric height segment to 0', () => {
assert.deepEqual(partsFromAddrPostHeightKey(`${ALICE}::post-a1`), {
addr: ALICE,
blockHeight: 0,
txid: 'post-a1'
})
})
})
@@ -1,3 +1,7 @@
# acceptance-mutation-manifest-begin
# {"version":1,"tested_at":"2026-09-20T22:26:28.547784872Z","feature_name":"Profile Recency Indexing","feature_path":"/home/trout/work/psf-memo/.worktrees/architect/psf-memo-indexer/specs/profile-recency-indexing.feature","background_hash":"2b6c5e5cbd326861a7a295e201fdba1c1dbf847d7b65909821167cdb138f47ef","implementation_hash":"unknown","scenarios":[]}
# acceptance-mutation-manifest-end
# Scenarios: Profile Recency Indexing - 1, Profile Recency Indexing - 2, Profile Recency Indexing - 3, Profile Recency Indexing - 4, Profile Recency Indexing - 5, Profile Recency Indexing - 6, Profile Recency Indexing - 7, Profile Recency Indexing - 8, Profile Recency Indexing - 9, Profile Recency Indexing - 10
#
# The indexer maintains a profileRecency store so the read side can list
@@ -54,5 +54,5 @@ class Adapters {
export default Adapters
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-29T03:35:13.645Z","module_hash":"242418b52e79c7a6f10aadb18839617f2135b16246db9a204d2888bc93c80d78","functions":[{"id":"func/Adapters.constructor","name":"Adapters.constructor","line":14,"end_line":42,"hash":"dc398f0bb823376f488088fc628f94913babcbe494cfd7dc299e230c08813836"},{"id":"func/Adapters.initAdapters","name":"Adapters.initAdapters","line":44,"end_line":47,"hash":"e3fd321225d51de1199b7ea45e4c9ab323a474c6fe99252041271e45daa59650"}]}
// {"version":1,"tested_at":"2026-09-20T22:03:25.604Z","module_hash":"7c072a095d3984b401d1eab04537690b78baff73211ad325364ddd4cb46379fa","functions":[{"id":"func/Adapters.constructor","name":"Adapters.constructor","line":14,"end_line":46,"hash":"fe2c96567b8ffaf157f7e64fff75e47ea33e2ddda13446048f37981976c4bb6f"},{"id":"func/Adapters.initAdapters","name":"Adapters.initAdapters","line":48,"end_line":51,"hash":"e3fd321225d51de1199b7ea45e4c9ab323a474c6fe99252041271e45daa59650"}]}
// mutate4javascript-manifest-end
@@ -48,3 +48,7 @@ export async function handlePost (ctx) {
await recordProfileRecency(adapters, signerAddr, blockHeight, seen)
}
}
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-09-20T22:02:16.774Z","module_hash":"20468da5cbcbef84fea21769827508bd962fa39aaec7de52705b6f35def32191","functions":[{"id":"func/createIfMissing","name":"createIfMissing","line":6,"end_line":12,"hash":"d59cefaf87075a2bc41538961b609387e35393d4fbf02ecfc0633026bbfdca42"},{"id":"func/qualifiesForRecency","name":"qualifiesForRecency","line":17,"end_line":19,"hash":"865f41dcba571b02183d6bac2b8bdfbc0d43c444eb2fadc68b7aa35e0bb4b7fd"},{"id":"func/handlePost","name":"handlePost","line":21,"end_line":50,"hash":"8eae8f0e6003b114b4b043aa298d57ab6562095c74ead3589d503af3c0e82314"}]}
// mutate4javascript-manifest-end
@@ -108,3 +108,7 @@ export async function establishProfileRecency (adapters, addr) {
if (!best) return null
return upsertProfileRecency(adapters, addr, best.blockHeight, best.seen)
}
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-09-20T22:21:12.817Z","module_hash":"4947b506b19056b74e5fa2b71a56977efc554e460f908123153701dffecb410d","functions":[{"id":"func/getChainBlockHeight","name":"getChainBlockHeight","line":21,"end_line":30,"hash":"496cbdf9c6911013be659fc4faf22c95e0871b989f9efe66cd6802b7c671bbc1"},{"id":"func/isConfirmed","name":"isConfirmed","line":34,"end_line":38,"hash":"3d803dc6482eefd4f09010edd1c4728ab5590cfe4d7e97e484324c28d08a8640"},{"id":"func/upsertProfileRecency","name":"upsertProfileRecency","line":42,"end_line":54,"hash":"465a6fc3736df2d1666b0bad083bc728ab19f704a664d53eb376875f4f9a8427"},{"id":"func/recordProfileRecency","name":"recordProfileRecency","line":58,"end_line":64,"hash":"4866765ec30a3c1b39bd68d1d51a9fd1ed2ea830182f9b7e45b55e948db67481"},{"id":"func/qualifyingCandidate","name":"qualifyingCandidate","line":68,"end_line":78,"hash":"c1595bafcea0d5ddc0c39d7ee30bea75250d3fabb46f9fd2925fc11dc182d827"},{"id":"func/newerCandidate","name":"newerCandidate","line":82,"end_line":86,"hash":"b9eabd80bee99447221c851694c9f1c0b9c8153e45e121580b72ce83cdfd766e"},{"id":"func/findNewestQualifyingPost","name":"findNewestQualifyingPost","line":88,"end_line":96,"hash":"e82f811d40c715d8c703837d4b0c0b40568d01622c902e5ebe504bf4dce56413"},{"id":"func/establishProfileRecency","name":"establishProfileRecency","line":103,"end_line":110,"hash":"bf58de2cfb1f41a13ce1964ec9fb5eb9939e6582307a6853d63ffe9603a7ffbc"}]}
// mutate4javascript-manifest-end
@@ -20,3 +20,7 @@ export async function handleSetProfile (ctx) {
await adapters.profileDb.create(signerAddr, { text, txid, seen, addr: signerAddr, blockHeight })
await establishProfileRecency(adapters, signerAddr)
}
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-09-20T22:12:37.670Z","module_hash":"527293404ff751265c80ca2abf4803b478e5786ad6bc3496616fb846668e4e02","functions":[{"id":"func/handleSetProfile","name":"handleSetProfile","line":5,"end_line":22,"hash":"4df97ed1a92e3d265b499abbb977e91ca80bd100b4d59138d4a3cd0d852fb25b"}]}
// mutate4javascript-manifest-end
@@ -1,10 +1,17 @@
import { assert } from 'chai'
import { makeMemoryDb } from '../../../support/memory-db.js'
import {
isConfirmed,
recordProfileRecency,
establishProfileRecency
} from '../../../../src/use-cases/action-types/profile-recency.js'
const ADDR = 'bitcoincash:qaddr-a'
function pad (height) {
return String(height).padStart(12, '0')
}
function makeAdapters (overrides = {}) {
return {
profileDb: makeMemoryDb(),
@@ -17,110 +24,159 @@ function makeAdapters (overrides = {}) {
}
}
// An adapter set with a stored profile for ADDR, the common precondition of
// the recordProfileRecency tests.
async function makeAdaptersWithProfile (overrides = {}) {
const adapters = makeAdapters(overrides)
await adapters.profileDb.update(ADDR, { addr: ADDR, text: 'bio' })
return adapters
}
function assertRecency (adapters, expected, addr = ADDR) {
assert.deepEqual(adapters.profileRecencyDb.store.get(addr), { addr, ...expected })
}
async function addAddrPost (adapters, txid, blockHeight, addr = ADDR) {
await adapters.addrPostHeightDb.update(`${addr}:${pad(blockHeight)}:${txid}`, {
txid, addr, blockHeight
})
}
async function addPost (adapters, txid, fields, addr = ADDR) {
await adapters.postDb.update(txid, { addr, ...fields })
}
describe('#isConfirmed', () => {
it('should treat a missing block height as unconfirmed', () => {
assert.isFalse(isConfirmed(null, 600100))
assert.isFalse(isConfirmed(undefined, 600100))
})
it('should treat a post at or below the chain tip as confirmed', () => {
assert.isTrue(isConfirmed(600100, 600100))
assert.isTrue(isConfirmed(600099, 600100))
assert.isFalse(isConfirmed(600101, 600100))
})
it('should treat every post as confirmed when there is no chain tip', () => {
assert.isTrue(isConfirmed(600100, null))
assert.isTrue(isConfirmed(600100, undefined))
})
})
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' })
const adapters = await makeAdaptersWithProfile()
await recordProfileRecency(adapters, 'bitcoincash:qaddr-a', 600100, 1000)
await recordProfileRecency(adapters, ADDR, 600100, 1000)
assert.deepEqual(adapters.profileRecencyDb.store.get('bitcoincash:qaddr-a'), {
addr: 'bitcoincash:qaddr-a',
blockHeight: 600100,
seen: 1000
})
assertRecency(adapters, { 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)
await recordProfileRecency(adapters, ADDR, 600100, 1000)
assert.equal(adapters.profileRecencyDb.store.size, 0)
})
it('should not record an unconfirmed post', async () => {
const adapters = makeAdapters({
const adapters = await makeAdaptersWithProfile({
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)
await recordProfileRecency(adapters, ADDR, 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' })
const adapters = await makeAdaptersWithProfile()
await recordProfileRecency(adapters, 'bitcoincash:qaddr-a', 600200, 100)
await recordProfileRecency(adapters, 'bitcoincash:qaddr-a', 600100, 200)
await recordProfileRecency(adapters, ADDR, 600200, 100)
await recordProfileRecency(adapters, ADDR, 600100, 200)
assert.equal(adapters.profileRecencyDb.store.get('bitcoincash:qaddr-a').blockHeight, 600200)
assert.equal(adapters.profileRecencyDb.store.get('bitcoincash:qaddr-a').seen, 100)
assertRecency(adapters, { blockHeight: 600200, 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' })
const adapters = await makeAdaptersWithProfile()
await recordProfileRecency(adapters, 'bitcoincash:qaddr-a', 600200, 100)
await recordProfileRecency(adapters, 'bitcoincash:qaddr-a', 600200, 300)
await recordProfileRecency(adapters, ADDR, 600200, 100)
await recordProfileRecency(adapters, ADDR, 600200, 300)
assert.equal(adapters.profileRecencyDb.store.get('bitcoincash:qaddr-a').seen, 300)
assertRecency(adapters, { blockHeight: 600200, 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' })
const adapters = await makeAdaptersWithProfile()
await recordProfileRecency(adapters, 'bitcoincash:qaddr-a', 600100, 100)
await recordProfileRecency(adapters, 'bitcoincash:qaddr-a', 600100, 100)
await recordProfileRecency(adapters, ADDR, 600100, 100)
await recordProfileRecency(adapters, ADDR, 600100, 100)
assert.deepEqual(adapters.profileRecencyDb.store.get('bitcoincash:qaddr-a'), {
addr: 'bitcoincash:qaddr-a',
blockHeight: 600100,
seen: 100
assertRecency(adapters, { blockHeight: 600100, seen: 100 })
})
it('should default a missing seen value to 0', async () => {
const adapters = await makeAdaptersWithProfile()
await recordProfileRecency(adapters, ADDR, 600100, null)
assertRecency(adapters, { blockHeight: 600100, seen: 0 })
})
it('should treat a recency record missing blockHeight as height 0', async () => {
const adapters = await makeAdaptersWithProfile()
await adapters.profileRecencyDb.update(ADDR, { addr: ADDR })
await recordProfileRecency(adapters, ADDR, 0, 1)
assertRecency(adapters, { blockHeight: 0, seen: 1 })
})
it('should treat a recency record missing seen as seen 0', async () => {
const adapters = await makeAdaptersWithProfile()
await adapters.profileRecencyDb.update(ADDR, { addr: ADDR, blockHeight: 600100 })
await recordProfileRecency(adapters, ADDR, 600100, 1)
assertRecency(adapters, { blockHeight: 600100, seen: 1 })
})
it('should return null when the profileRecency store is not configured', async () => {
const adapters = await makeAdaptersWithProfile({ profileRecencyDb: undefined })
const result = await recordProfileRecency(adapters, ADDR, 600100, 100)
assert.equal(result, null)
})
})
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 addAddrPost(adapters, 'post-a1', 600100)
await addAddrPost(adapters, 'poll-a1', 600200)
await addAddrPost(adapters, 'reply-a1', 600300)
await addPost(adapters, 'post-a1', { 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')
await establishProfileRecency(adapters, ADDR)
assert.deepEqual(adapters.profileRecencyDb.store.get('bitcoincash:qaddr-a'), {
addr: 'bitcoincash:qaddr-a',
blockHeight: 600100,
seen: 100
})
assertRecency(adapters, { 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 })
const addr = 'bitcoincash:qaddr-b'
await addAddrPost(adapters, 'post-b2', 600500, addr)
await addPost(adapters, 'post-b2', { seen: 250, blockHeight: 600500 }, addr)
await establishProfileRecency(adapters, 'bitcoincash:qaddr-b')
await establishProfileRecency(adapters, addr)
assert.equal(adapters.profileRecencyDb.store.size, 0)
})
@@ -132,4 +188,58 @@ describe('#establishProfileRecency', () => {
assert.equal(adapters.profileRecencyDb.store.size, 0)
})
it('should default a missing addrPostHeight block height to 0', async () => {
const adapters = makeAdapters()
await adapters.addrPostHeightDb.update(`${ADDR}:${pad(600100)}:post-a1`, {
txid: 'post-a1', addr: ADDR
})
await addPost(adapters, 'post-a1', { seen: 42 })
await establishProfileRecency(adapters, ADDR)
assertRecency(adapters, { blockHeight: 0, seen: 42 })
})
it('should default a missing post seen to 0', async () => {
const adapters = makeAdapters()
await addAddrPost(adapters, 'post-a1', 600100)
await adapters.postDb.update('post-a1', { addr: ADDR })
await establishProfileRecency(adapters, ADDR)
assertRecency(adapters, { blockHeight: 600100, seen: 0 })
})
it('should pick the newest of several qualifying posts', async () => {
const adapters = makeAdapters()
await addAddrPost(adapters, 'post-a1', 600100)
await addAddrPost(adapters, 'post-a2', 600300)
await addPost(adapters, 'post-a1', { seen: 100 })
await addPost(adapters, 'post-a2', { seen: 300 })
await establishProfileRecency(adapters, ADDR)
assertRecency(adapters, { blockHeight: 600300, seen: 300 })
})
it('should return null when the addrPostHeight store is not configured', async () => {
const adapters = makeAdapters({ addrPostHeightDb: undefined })
const result = await establishProfileRecency(adapters, ADDR)
assert.equal(result, null)
})
it('should treat a status read error as no chain tip', async () => {
const adapters = makeAdapters({
statusDb: { getStatus: async () => { throw new Error('status down') } }
})
await addAddrPost(adapters, 'post-a1', 600100)
await addPost(adapters, 'post-a1', { seen: 100 })
await establishProfileRecency(adapters, ADDR)
assertRecency(adapters, { blockHeight: 600100, seen: 100 })
})
})
@@ -1,6 +1,7 @@
import { assert } from 'chai'
import { makeMemoryDb } from '../../../support/memory-db.js'
import { handleSetProfile } from '../../../../src/use-cases/action-types/set-profile.js'
import { MAX_POST_SIZE } from '../../../../src/lib/memo-codes.js'
const PREFIX_SET_PROFILE = Buffer.from('6d05', 'hex')
@@ -46,6 +47,7 @@ describe('#handleSetProfile', () => {
blockHeight: 600100,
seen: 100
})
assert.equal(adapters.profileDb.store.get('bitcoincash:qaddr-a').text, 'my bio')
})
it('should not create a recency record when the address has no qualifying post', async () => {
@@ -66,4 +68,44 @@ describe('#handleSetProfile', () => {
assert.equal(adapters.profileRecencyDb.store.size, 0)
})
it('should accept a profile whose text is exactly the maximum size', async () => {
const adapters = makeAdapters()
const text = 'a'.repeat(MAX_POST_SIZE)
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(text)]
}
})
assert.equal(adapters.profileDb.store.get('bitcoincash:qaddr-a').text.length, MAX_POST_SIZE)
})
it('should reject a profile larger than the maximum size', async () => {
const adapters = makeAdapters()
const text = 'a'.repeat(MAX_POST_SIZE + 1)
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(text)]
}
})
assert.isFalse(adapters.profileDb.store.has('bitcoincash:qaddr-a'))
})
})