diff --git a/src/adapters/index.js b/src/adapters/index.js index 9b62a61..30b7db3 100644 --- a/src/adapters/index.js +++ b/src/adapters/index.js @@ -23,7 +23,9 @@ class Adapters { profilesDb: level.profilesDb }) this.postQuery = new PostQuery({ - postsDb: level.postsDb + postsDb: level.postsDb, + postParentsDb: level.postParentsDb, + postChildrenDb: level.postChildrenDb }) return true } diff --git a/src/adapters/post-query.js b/src/adapters/post-query.js index 20fe093..ea162bd 100644 --- a/src/adapters/post-query.js +++ b/src/adapters/post-query.js @@ -1,27 +1,67 @@ /* Adapter for scanning posts with stored block height. + Excludes reply posts (txids present in postParentsDb). */ class PostQuery { constructor (localConfig = {}) { - const { postsDb } = localConfig + const { postsDb, postParentsDb, postChildrenDb } = localConfig if (!postsDb) { throw new Error('postsDb required when instantiating PostQuery adapter.') } + if (!postParentsDb) { + throw new Error('postParentsDb required when instantiating PostQuery adapter.') + } + if (!postChildrenDb) { + throw new Error('postChildrenDb required when instantiating PostQuery adapter.') + } this.postsDb = postsDb + this.postParentsDb = postParentsDb + this.postChildrenDb = postChildrenDb this.scanPostsWithBlockHeight = this.scanPostsWithBlockHeight.bind(this) + this.scanPostsByAddr = this.scanPostsByAddr.bind(this) + this.loadReplyTxids = this.loadReplyTxids.bind(this) + this.buildReplyCountMap = this.buildReplyCountMap.bind(this) + } + + async loadReplyTxids () { + const replyTxids = new Set() + + for await (const [childTxid] of this.postParentsDb.iterator()) { + replyTxids.add(childTxid) + } + + return replyTxids + } + + async buildReplyCountMap () { + const counts = new Map() + + for await (const [, child] of this.postChildrenDb.iterator()) { + const parentTxid = child.parentTxid + if (!parentTxid) continue + counts.set(parentTxid, (counts.get(parentTxid) || 0) + 1) + } + + return counts } async scanPostsWithBlockHeight () { + const [replyTxids, replyCounts] = await Promise.all([ + this.loadReplyTxids(), + this.buildReplyCountMap() + ]) const posts = [] for await (const [txid, post] of this.postsDb.iterator()) { + if (replyTxids.has(txid)) continue posts.push({ txid, addr: post.addr, text: post.text, seen: post.seen, - blockHeight: post.blockHeight ?? 0 + blockHeight: post.blockHeight ?? 0, + replyCount: replyCounts.get(txid) ?? 0 }) } @@ -29,16 +69,22 @@ class PostQuery { } async scanPostsByAddr (addr) { + const [replyTxids, replyCounts] = await Promise.all([ + this.loadReplyTxids(), + this.buildReplyCountMap() + ]) const posts = [] for await (const [txid, post] of this.postsDb.iterator()) { if (post.addr !== addr) continue + if (replyTxids.has(txid)) continue posts.push({ txid, addr: post.addr, text: post.text, seen: post.seen, - blockHeight: post.blockHeight ?? 0 + blockHeight: post.blockHeight ?? 0, + replyCount: replyCounts.get(txid) ?? 0 }) } diff --git a/src/controllers/rest-api/posts/controller.js b/src/controllers/rest-api/posts/controller.js index 68a00e3..37bb2b0 100644 --- a/src/controllers/rest-api/posts/controller.js +++ b/src/controllers/rest-api/posts/controller.js @@ -35,7 +35,7 @@ class PostsRESTControllerLib { * @apiName GetRecentPosts * @apiGroup REST Posts * - * @apiDescription Returns posts sorted by block height (newest first), with seen timestamp as tie-breaker. + * @apiDescription Returns top-level posts only (replies excluded), sorted by block height (newest first), with seen timestamp as tie-breaker. * * @apiQuery {Number} [limit=100] Page size (max 100) * @apiQuery {Number} [offset=0] Number of posts to skip after sorting @@ -49,6 +49,7 @@ class PostsRESTControllerLib { * @apiSuccess {String} posts.text Post text * @apiSuccess {Number} posts.seen Unix epoch milliseconds * @apiSuccess {Number} posts.blockHeight Block height when indexed + * @apiSuccess {Number} posts.replyCount Number of replies to this post * @apiSuccess {Object} pagination Pagination metadata * @apiSuccess {Number} pagination.limit Page size used * @apiSuccess {Number} pagination.offset Offset used @@ -70,7 +71,7 @@ class PostsRESTControllerLib { * @apiName GetPostsByAddr * @apiGroup REST Posts * - * @apiDescription Returns posts for a single address sorted by block height (newest first). + * @apiDescription Returns top-level posts for a single address (replies excluded), sorted by block height (newest first). * * @apiParam {String} addr Author cash address * @apiQuery {Number} [limit=100] Page size (max 100) @@ -85,6 +86,7 @@ class PostsRESTControllerLib { * @apiSuccess {String} posts.text Post text * @apiSuccess {Number} posts.seen Unix epoch milliseconds * @apiSuccess {Number} posts.blockHeight Block height when indexed + * @apiSuccess {Number} posts.replyCount Number of replies to this post * @apiSuccess {Object} pagination Pagination metadata */ async getPostsByAddr (ctx) { diff --git a/test/unit/adapters/post-query.unit.js b/test/unit/adapters/post-query.unit.js index 683725d..c7cc3a1 100644 --- a/test/unit/adapters/post-query.unit.js +++ b/test/unit/adapters/post-query.unit.js @@ -6,13 +6,25 @@ describe('#PostQuery', () => { let uut let sandbox let postsDb + let postParentsDb + let postChildrenDb beforeEach(() => { sandbox = sinon.createSandbox() postsDb = { iterator: sandbox.stub() } - uut = new PostQuery({ postsDb }) + postParentsDb = { + iterator: sandbox.stub() + } + postChildrenDb = { + iterator: sandbox.stub() + } + async function * emptyParents () {} + async function * emptyChildren () {} + postParentsDb.iterator.returns(emptyParents()) + postChildrenDb.iterator.returns(emptyChildren()) + uut = new PostQuery({ postsDb, postParentsDb, postChildrenDb }) }) afterEach(() => sandbox.restore()) @@ -29,8 +41,10 @@ describe('#PostQuery', () => { assert.equal(result.length, 2) assert.equal(result[0].txid, 'tx1') assert.equal(result[0].blockHeight, 600100) + assert.equal(result[0].replyCount, 0) assert.equal(result[1].txid, 'tx2') assert.equal(result[1].blockHeight, 600200) + assert.equal(result[1].replyCount, 0) }) it('should use block height 0 when field is missing', async () => { @@ -42,6 +56,7 @@ describe('#PostQuery', () => { const result = await uut.scanPostsWithBlockHeight() assert.equal(result[0].blockHeight, 0) + assert.equal(result[0].replyCount, 0) }) it('should scan posts for a single address', async () => { @@ -58,4 +73,72 @@ describe('#PostQuery', () => { assert.equal(result[0].txid, 'tx1') assert.equal(result[1].txid, 'tx3') }) + + it('should exclude reply posts from recent scan', async () => { + async function * mockParents () { + yield ['tx-reply', { parentTxid: 'tx1', childTxid: 'tx-reply', blockHeight: 600150 }] + } + async function * mockPosts () { + yield ['tx1', { addr: 'addr1', text: 'top', seen: 1000, blockHeight: 600100 }] + yield ['tx-reply', { addr: 'addr1', text: 'reply', seen: 1500, blockHeight: 600150 }] + yield ['tx2', { addr: 'addr2', text: 'other', seen: 2000, blockHeight: 600200 }] + } + postParentsDb.iterator.returns(mockParents()) + postsDb.iterator.returns(mockPosts()) + + const result = await uut.scanPostsWithBlockHeight() + + assert.equal(result.length, 2) + assert.equal(result[0].txid, 'tx1') + assert.equal(result[1].txid, 'tx2') + }) + + it('should exclude reply posts from address scan', async () => { + async function * mockParents () { + yield ['tx-reply', { parentTxid: 'tx1', childTxid: 'tx-reply', blockHeight: 600150 }] + } + async function * mockPosts () { + yield ['tx1', { addr: 'addr-a', text: 'top', seen: 1000, blockHeight: 600100 }] + yield ['tx-reply', { addr: 'addr-a', text: 'reply', seen: 1500, blockHeight: 600150 }] + } + postParentsDb.iterator.returns(mockParents()) + postsDb.iterator.returns(mockPosts()) + + const result = await uut.scanPostsByAddr('addr-a') + + assert.equal(result.length, 1) + assert.equal(result[0].txid, 'tx1') + }) + + it('should include replyCount from postChildren scan', async () => { + async function * mockChildren () { + yield ['tx1:reply-a', { parentTxid: 'tx1', childTxid: 'reply-a', blockHeight: 600150 }] + yield ['tx1:reply-b', { parentTxid: 'tx1', childTxid: 'reply-b', blockHeight: 600160 }] + yield ['tx2:reply-c', { parentTxid: 'tx2', childTxid: 'reply-c', blockHeight: 600170 }] + } + async function * mockPosts () { + yield ['tx1', { addr: 'addr1', text: 'top', seen: 1000, blockHeight: 600100 }] + yield ['tx2', { addr: 'addr2', text: 'other', seen: 2000, blockHeight: 600200 }] + } + postChildrenDb.iterator.returns(mockChildren()) + postsDb.iterator.returns(mockPosts()) + + const result = await uut.scanPostsWithBlockHeight() + + assert.equal(result.length, 2) + assert.equal(result.find((p) => p.txid === 'tx1').replyCount, 2) + assert.equal(result.find((p) => p.txid === 'tx2').replyCount, 1) + }) + + it('should default replyCount to 0 when post has no replies', async () => { + async function * mockPosts () { + yield ['tx1', { addr: 'addr1', text: 'solo', seen: 1000, blockHeight: 600100 }] + } + postsDb.iterator.returns(mockPosts()) + + const result = await uut.scanPostsByAddr('addr1') + + assert.equal(result.length, 1) + assert.equal(result[0].replyCount, 0) + }) })