From 5653ecef9dada71c764494d707348f79ba8b0154 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Sun, 20 Sep 2026 08:32:43 -0700 Subject: [PATCH] Refactor following feed cap: share acceptance seeding, add cap properties Extract putPost/putFollowEdge seeding helpers in the psf-memo-db acceptance handlers and route the following-feed capped/mixed fixtures and the many-top-level-posts fixture through them, removing repeated store/key writes. Add property tests for the capped total, newest-first page conservation, and the bounded eligible scan, plus unit coverage for the remaining PostQuery constructor guards and static key helpers (post-query.js statements 94.5% -> 99.4%). CRAP stays at or below 6, src/adapters/post-query.js remains at 40 mutation sites with its manifest intact, and the DRY tool reports no duplicates in the changed source file. By refactorer. --- psf-memo-db/acceptance/lib/handlers.js | 86 +++++++++-------- .../following-feed-query.property.test.js | 95 +++++++++++++++++++ .../test/unit/adapters/post-query.unit.js | 53 +++++++++++ 3 files changed, 196 insertions(+), 38 deletions(-) diff --git a/psf-memo-db/acceptance/lib/handlers.js b/psf-memo-db/acceptance/lib/handlers.js index c463c9c..e3f2b96 100644 --- a/psf-memo-db/acceptance/lib/handlers.js +++ b/psf-memo-db/acceptance/lib/handlers.js @@ -44,6 +44,39 @@ function padHeight (blockHeight) { return String(blockHeight ?? 0).padStart(12, '0') } +// Shared fixture seeding helpers so every loader writes the same post and +// follow records without repeating the store/key shape at each call site. +async function putFollowEdge (world, { followerAddr, followeeAddr, txid, blockHeight = 600000 }) { + const followeePkHash = hash160(followeeAddr) + await world.adapters.level.followsDb.put(`${followerAddr}:${followeePkHash}`, { + followerAddr, + followeePkHash, + unfollow: false, + txid, + seen: 1, + blockHeight + }) +} + +async function putPost (world, { txid, addr, text = txid, seen = 0, blockHeight, withAddrIndex = false }) { + await world.adapters.level.postsDb.put(txid, { + addr, + text, + seen, + blockHeight + }) + await world.adapters.level.postHeightsDb.put( + `${padHeight(blockHeight)}:${txid}`, + { txid, blockHeight } + ) + if (withAddrIndex) { + await world.adapters.level.addrPostHeightsDb.put( + `${addr}:${padHeight(blockHeight)}:${txid}`, + { txid, addr, blockHeight } + ) + } +} + const __dirname = path.dirname(fileURLToPath(import.meta.url)) const tmpDir = path.resolve(__dirname, '..', '..', 'tmp', 'acceptance') @@ -467,22 +500,14 @@ async function loadManyTopLevelPosts (world) { // index and reads all 510 entries. for (let i = 0; i < 510; i++) { const id = String(i).padStart(3, '0') - const txid = `post-${id}` - const blockHeight = 600000 + i - await world.adapters.level.postsDb.put(txid, { + await putPost(world, { + txid: `post-${id}`, addr: 'bitcoincash:qaddr', text: `post ${id}`, seen: i, - blockHeight + blockHeight: 600000 + i, + withAddrIndex: true }) - await world.adapters.level.postHeightsDb.put( - String(blockHeight).padStart(12, '0') + ':' + txid, - { txid, blockHeight } - ) - await world.adapters.level.addrPostHeightsDb.put( - `bitcoincash:qaddr:${String(blockHeight).padStart(12, '0')}:${txid}`, - { txid, addr: 'bitcoincash:qaddr', blockHeight } - ) } } @@ -491,30 +516,22 @@ async function loadFollowingFeedCapped (world) { const followee = 'bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy' world.fixtureViewer = viewer - await world.adapters.level.followsDb.put(`${viewer}:${hash160(followee)}`, { + await putFollowEdge(world, { followerAddr: viewer, - followeePkHash: hash160(followee), - unfollow: false, - txid: 'follow-capped', - seen: 1, - blockHeight: 600000 + followeeAddr: followee, + txid: 'follow-capped' }) // 510 eligible top-level posts, larger than the 500 total-scan cap. for (let i = 0; i < 510; i++) { const id = String(i).padStart(3, '0') - const txid = `post-${id}` - const blockHeight = 600000 + i - await world.adapters.level.postsDb.put(txid, { + await putPost(world, { + txid: `post-${id}`, addr: followee, text: `post ${id}`, seen: i, - blockHeight + blockHeight: 600000 + i }) - await world.adapters.level.postHeightsDb.put( - `${padHeight(blockHeight)}:${txid}`, - { txid, blockHeight } - ) } } @@ -527,13 +544,10 @@ async function loadFollowingFeedMixed (world) { const other = 'bitcoincash:qpm2qsznhks23z7629mms6s4cwef74vcwvy22gdx6a' world.fixtureViewer = viewer - await world.adapters.level.followsDb.put(`${viewer}:${hash160(followee)}`, { + await putFollowEdge(world, { followerAddr: viewer, - followeePkHash: hash160(followee), - unfollow: false, - txid: 'follow-mixed', - seen: 1, - blockHeight: 600000 + followeeAddr: followee, + txid: 'follow-mixed' }) const posts = [ @@ -544,16 +558,12 @@ async function loadFollowingFeedMixed (world) { { txid: 'reply-A2', addr: followee, blockHeight: 600300 } ] for (const post of posts) { - await world.adapters.level.postsDb.put(post.txid, { + await putPost(world, { + txid: post.txid, addr: post.addr, - text: post.txid, seen: post.blockHeight, blockHeight: post.blockHeight }) - await world.adapters.level.postHeightsDb.put( - `${padHeight(post.blockHeight)}:${post.txid}`, - { txid: post.txid, blockHeight: post.blockHeight } - ) } const reply = { txid: 'reply-A2', parentTxid: 'post-A2', childTxid: 'reply-A2', blockHeight: 600300 } diff --git a/psf-memo-db/test/property/following-feed-query.property.test.js b/psf-memo-db/test/property/following-feed-query.property.test.js index cb36057..50d9e14 100644 --- a/psf-memo-db/test/property/following-feed-query.property.test.js +++ b/psf-memo-db/test/property/following-feed-query.property.test.js @@ -185,3 +185,98 @@ test('following-feed scan never returns replies, the viewer, or un-followed auth { label: 'following-feed reply/viewer/membership exclusion' } ) }) + +// All-eligible corpus: every indexed entry is a top-level post by the single +// followed author, so the scan's eligible count equals the number of posts it +// reads. `cap` varies the total-scan cap so the capped and below-cap branches +// are both exercised. +function cappedCorpusGen () { + return () => { + const n = intGen(rng, 0, 25)() + const followee = 'bitcoincash:f1' + const posts = [] + const postHeights = [] + + for (let i = 0; i < n; i++) { + const txid = txidGen(rng) + const height = intGen(rng, 0, 9000000)() + posts.push({ txid, addr: followee, text: 'post ' + i, seen: i, blockHeight: height }) + postHeights.push({ key: PostQuery.postHeightKey(height, txid), value: { txid } }) + } + + return { + n, + posts, + postHeights, + followed: [VIEWER, followee], + limit: intGen(rng, 1, 8)(), + offset: intGen(rng, 0, 12)(), + cap: intGen(rng, 1, 12)() + } + } +} + +function orderedPostTxids (posts) { + return [...posts] + .sort((a, b) => (PostQuery.postHeightKey(a.blockHeight, a.txid) < PostQuery.postHeightKey(b.blockHeight, b.txid) ? 1 : -1)) + .map((p) => p.txid) +} + +test('following-feed scan caps the total and conserves the newest-first page', async () => { + await forAll( + cappedCorpusGen(), + async ({ n, posts, postHeights, followed, limit, offset, cap }) => { + const query = makeQuery(postHeights, posts, new Set()) + const { txids, total } = await query.scanFollowingFeedTxidsAndCount( + VIEWER, + followed, + { limit, offset, totalScanCap: cap } + ) + + const expectedTotal = Math.min(n, cap) + const expectedTxids = orderedPostTxids(posts).slice(offset, offset + limit) + + return total === expectedTotal && JSON.stringify(txids) === JSON.stringify(expectedTxids) + }, + { label: 'following-feed capped-total and page conservation' } + ) +}) + +test('following-feed scan reads at most offset + limit + cap eligible posts', async () => { + await forAll( + cappedCorpusGen(), + async ({ n, posts, postHeights, followed, limit, offset, cap }) => { + const counter = { reads: 0 } + const store = new Map(posts.map((p) => [p.txid, p])) + const query = new PostQuery({ + postsDb: { + async get (txid) { + counter.reads++ + const post = store.get(txid) + if (!post) { + const err = new Error('not found') + err.notFound = true + throw err + } + return post + } + }, + postHeightsDb: makePostHeightsDb(postHeights), + addrPostHeightsDb: {}, + postParentsDb: makeParentsDb(new Set()), + postChildrenDb: {}, + likesDb: {}, + postLikesDb: {} + }) + + await query.scanFollowingFeedTxidsAndCount( + VIEWER, + followed, + { limit, offset, totalScanCap: cap } + ) + + return counter.reads === Math.min(n, offset + limit + cap) + }, + { label: 'following-feed bounded eligible scan' } + ) +}) diff --git a/psf-memo-db/test/unit/adapters/post-query.unit.js b/psf-memo-db/test/unit/adapters/post-query.unit.js index b870ae0..3b04f0e 100644 --- a/psf-memo-db/test/unit/adapters/post-query.unit.js +++ b/psf-memo-db/test/unit/adapters/post-query.unit.js @@ -94,6 +94,59 @@ describe('#PostQuery', () => { } }) + it('should throw when postsDb is missing', () => { + try { + // eslint-disable-next-line no-new + new PostQuery({ postHeightsDb, addrPostHeightsDb, postParentsDb, postChildrenDb, likesDb, postLikesDb }) + assert.fail('Expected error') + } catch (err) { + assert.include(err.message, 'postsDb required') + } + }) + + it('should throw when postParentsDb is missing', () => { + try { + // eslint-disable-next-line no-new + new PostQuery({ postsDb, postHeightsDb, addrPostHeightsDb, postChildrenDb, likesDb, postLikesDb }) + assert.fail('Expected error') + } catch (err) { + assert.include(err.message, 'postParentsDb required') + } + }) + + it('should throw when postChildrenDb is missing', () => { + try { + // eslint-disable-next-line no-new + new PostQuery({ postsDb, postHeightsDb, addrPostHeightsDb, postParentsDb, likesDb, postLikesDb }) + assert.fail('Expected error') + } catch (err) { + assert.include(err.message, 'postChildrenDb required') + } + }) + + it('should throw when likesDb is missing', () => { + try { + // eslint-disable-next-line no-new + new PostQuery({ postsDb, postHeightsDb, addrPostHeightsDb, postParentsDb, postChildrenDb, postLikesDb }) + assert.fail('Expected error') + } catch (err) { + assert.include(err.message, 'likesDb required') + } + }) + + describe('#static key helpers', () => { + it('should pad block heights to a fixed width for lexicographic ordering', () => { + assert.equal(PostQuery.padHeight(600200), '000000600200') + assert.equal(PostQuery.padHeight(1), '000000000001') + }) + + it('should compose postHeight, addrPostHeight, and postLike keys', () => { + assert.equal(PostQuery.postHeightKey(600200, 'post-a'), '000000600200:post-a') + assert.equal(PostQuery.addrPostHeightKey('bitcoincash:addr', 600200, 'post-a'), 'bitcoincash:addr:000000600200:post-a') + assert.equal(PostQuery.postLikeKey('post-a', 'like-1'), 'post-a:like-1') + }) + }) + describe('#txidFromPostHeight', () => { it('should return the txid from the value when present', () => { assert.equal(uut.txidFromPostHeight('any-key', { txid: 'abc123' }), 'abc123')