Implement efficient post query with addrPostHeights and postLikes indexes

- Add addrPostHeights and postLikes LevelDB stores to psf-memo-db.
- Update PostQuery to use addrPostHeights for /posts/by/:addr and postLikes for per-post counts.
- Add backfill utility for the new indexes.
- Update indexer to write addrPostHeights (posts/replies) and postLikes entries.
- Add acceptance handlers and unit tests for the new behavior.

By coder.
This commit is contained in:
Chris Troutner
2026-08-27 14:55:09 -07:00
parent a5dc01ede5
commit a68151b5f5
21 changed files with 819 additions and 160 deletions
+179 -6
View File
@@ -74,13 +74,17 @@ async function createWorld () {
adapters.start()
const postHeightsIteratorCounter = { calls: 0 }
const addrPostHeightsIteratorCounter = { calls: 0 }
const postChildrenIteratorCounter = { calls: 0 }
const postsGetCounter = { calls: 0 }
const likesIteratorCounter = { calls: 0 }
const postLikesIteratorCounter = { calls: 0 }
wrapIterator(adapters.level.postHeightsDb, postHeightsIteratorCounter)
wrapIterator(adapters.level.addrPostHeightsDb, addrPostHeightsIteratorCounter)
wrapIterator(adapters.level.postChildrenDb, postChildrenIteratorCounter)
wrapGet(adapters.level.postsDb, postsGetCounter)
wrapIterator(adapters.level.likesDb, likesIteratorCounter)
wrapIterator(adapters.level.postLikesDb, postLikesIteratorCounter)
const listRecentPosts = new ListRecentPosts({ adapters })
const listPostsByAddr = new ListPostsByAddr({ adapters })
@@ -100,9 +104,11 @@ async function createWorld () {
listFollowing,
listFollowers,
postHeightsIteratorCounter,
addrPostHeightsIteratorCounter,
postChildrenIteratorCounter,
postsGetCounter,
likesIteratorCounter,
postLikesIteratorCounter,
getLastResponse: () => lastResponse,
setLastResponse: (resp) => { lastResponse = resp },
close: async () => {
@@ -121,6 +127,16 @@ async function loadFixture (world, name) {
return
}
if (name === 'posts-with-likes-and-indexes') {
await loadPostsWithLikes(world)
return
}
if (name === 'posts-and-likes-without-indexes') {
await loadPostsAndLikesCore(world)
return
}
if (name === 'follows') {
await loadFollows(world)
return
@@ -148,6 +164,10 @@ async function loadFixture (world, name) {
String(post.blockHeight).padStart(12, '0') + ':' + post.txid,
{ txid: post.txid, blockHeight: post.blockHeight }
)
await world.adapters.level.addrPostHeightsDb.put(
`${post.addr}:${String(post.blockHeight).padStart(12, '0')}:${post.txid}`,
{ txid: post.txid, addr: post.addr, blockHeight: post.blockHeight }
)
}
await world.adapters.level.postsDb.put('reply-1', {
@@ -164,7 +184,7 @@ async function loadFixture (world, name) {
await world.adapters.level.postChildrenDb.put('post-200-a:reply-1', reply)
}
async function loadPostsWithLikes (world) {
async function loadPostsAndLikesCore (world) {
const posts = [
{ txid: 'post-200-a', addr: 'bitcoincash:qaddr-a', text: 'a', seen: 100, blockHeight: 600200 },
{ txid: 'post-200-b', addr: 'bitcoincash:qaddr-b', text: 'b', seen: 200, blockHeight: 600200 },
@@ -211,6 +231,73 @@ async function loadPostsWithLikes (world) {
}
}
async function loadPostsWithLikes (world) {
await loadPostsAndLikesCore(world)
// Also populate the secondary indexes expected by the efficient-query read path.
const topLevelPosts = [
{ txid: 'post-200-a', addr: 'bitcoincash:qaddr-a', blockHeight: 600200 },
{ txid: 'post-200-b', addr: 'bitcoincash:qaddr-b', blockHeight: 600200 },
{ txid: 'post-100', addr: 'bitcoincash:qaddr-a', blockHeight: 600100 }
]
for (const post of topLevelPosts) {
await world.adapters.level.addrPostHeightsDb.put(
`${post.addr}:${String(post.blockHeight).padStart(12, '0')}:${post.txid}`,
{ txid: post.txid, addr: post.addr, blockHeight: post.blockHeight }
)
}
const likes = [
{ txid: 'like-1', postTxid: 'post-200-a' },
{ txid: 'like-2', postTxid: 'post-200-a' },
{ txid: 'like-3', postTxid: 'post-200-b' },
{ txid: 'like-4', postTxid: 'reply-1' }
]
for (const like of likes) {
await world.adapters.level.postLikesDb.put(
`${like.postTxid}:${like.txid}`,
{ postTxid: like.postTxid, txid: like.txid }
)
}
}
async function backfillIndexes (world) {
const posts = []
for await (const [txid, post] of world.adapters.level.postsDb.iterator()) {
posts.push({ txid, ...post })
}
for (const post of posts) {
const key = `${post.addr}:${String(post.blockHeight ?? 0).padStart(12, '0')}:${post.txid}`
try {
await world.adapters.level.addrPostHeightsDb.get(key)
} catch (err) {
if (err.notFound || err.code === 'LEVEL_NOT_FOUND') {
await world.adapters.level.addrPostHeightsDb.put(key, {
txid: post.txid,
addr: post.addr,
blockHeight: post.blockHeight ?? 0
})
}
}
}
for await (const [txid, like] of world.adapters.level.likesDb.iterator()) {
if (!like.postTxid) continue
const key = `${like.postTxid}:${txid}`
try {
await world.adapters.level.postLikesDb.get(key)
} catch (err) {
if (err.notFound || err.code === 'LEVEL_NOT_FOUND') {
await world.adapters.level.postLikesDb.put(key, {
postTxid: like.postTxid,
txid
})
}
}
}
}
async function loadFollows (world) {
const follower1 = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
const follower2 = 'bitcoincash:qpm2qsznhks23z7629mms6s4cwef74vcwvy22gdx6a'
@@ -248,6 +335,13 @@ const handlers = [
// World is already created with both stores.
}
},
{
name: 'db instance with new indexes',
pattern: /^a psf-memo-db instance with posts, postHeights, addrPostHeights, (?:postChildren, )?likes, and postLikes stores$/,
async run () {
// World is already created with all stores.
}
},
{
name: 'load fixture',
pattern: /^the fixture "(.+)" is loaded into the posts (?:store|and likes stores)$/,
@@ -262,6 +356,20 @@ const handlers = [
await loadFixture(world, m[1])
}
},
{
name: 'run backfill utility',
pattern: /^the backfill utility is run$/,
async run (m, example, world) {
await backfillIndexes(world)
}
},
{
name: 'run backfill utility again',
pattern: /^the backfill utility is run again$/,
async run (m, example, world) {
await backfillIndexes(world)
}
},
{
name: 'request recent posts',
pattern: /^the client requests \/posts\/recent with limit (<limit>) and offset (<offset>)$/,
@@ -345,6 +453,17 @@ const handlers = [
}
}
},
{
name: 'bounded addrPostHeights reads',
pattern: /^no more than (<limit>) addrPostHeights entries are read after applying the offset$/,
run (m, example, world) {
const limit = parseInt(resolveParam(m[1], example), 10)
const reads = world.addrPostHeightsIteratorCounter.calls
if (reads > limit) {
throw new Error(`Read ${reads} addrPostHeights entries, expected at most ${limit}`)
}
}
},
{
name: 'bounded posts loaded by txid',
pattern: /^no more than (<limit>) posts are loaded by txid$/,
@@ -371,6 +490,27 @@ const handlers = [
}
}
},
{
name: 'bounded postChildren iterations',
pattern: /^the postChildren store was iterated at most (<max_iterations>) times$/,
run (m, example, world) {
const max = parseInt(resolveParam(m[1], example), 10)
const calls = world.postChildrenIteratorCounter.calls
if (calls > max) {
throw new Error(`Expected at most ${max} postChildren iterations, got ${calls}`)
}
}
},
{
name: 'single postChildren scan',
pattern: /^the postChildren store was iterated exactly once$/,
run (m, example, world) {
const calls = world.postChildrenIteratorCounter.calls
if (calls !== 1) {
throw new Error(`Expected exactly one postChildren scan, got ${calls}`)
}
}
},
{
name: 'request recent posts endpoint',
pattern: /^the client requests the recent posts endpoint$/,
@@ -419,12 +559,45 @@ const handlers = [
}
},
{
name: 'single postChildren scan',
pattern: /^the postChildren store was iterated exactly once$/,
name: 'bounded postLikes iterations',
pattern: /^the postLikes store was iterated at most (<max_iterations>) times$/,
run (m, example, world) {
const calls = world.postChildrenIteratorCounter.calls
if (calls !== 1) {
throw new Error(`Expected exactly one postChildren scan, got ${calls}`)
const max = parseInt(resolveParam(m[1], example), 10)
const calls = world.postLikesIteratorCounter.calls
if (calls > max) {
throw new Error(`Expected at most ${max} postLikes iterations, got ${calls}`)
}
}
},
{
name: 'addrPostHeights contains entry',
pattern: /^the addrPostHeights store contains (<count>) entry whose key starts with (<addr>) and ends with (<postTxid>)$/,
async run (m, example, world) {
const expectedCount = parseInt(resolveParam(m[1], example), 10)
const addr = resolveParam(m[2], example)
const txid = resolveParam(m[3], example)
let count = 0
for await (const [key] of world.adapters.level.addrPostHeightsDb.iterator()) {
if (key.startsWith(`${addr}:`) && key.endsWith(`:${txid}`)) count++
}
if (count !== expectedCount) {
throw new Error(`Expected ${expectedCount} addrPostHeights entry/entries for ${addr}/${txid}, got ${count}`)
}
}
},
{
name: 'postLikes contains entry',
pattern: /^the postLikes store contains (<count>) entry whose key starts with (<postTxid>) and ends with (<likeTxid>)$/,
async run (m, example, world) {
const expectedCount = parseInt(resolveParam(m[1], example), 10)
const postTxid = resolveParam(m[2], example)
const likeTxid = resolveParam(m[3], example)
let count = 0
for await (const [key] of world.adapters.level.postLikesDb.iterator()) {
if (key.startsWith(`${postTxid}:`) && key.endsWith(`:${likeTxid}`)) count++
}
if (count !== expectedCount) {
throw new Error(`Expected ${expectedCount} postLikes entry/entries for ${postTxid}/${likeTxid}, got ${count}`)
}
}
},
+3 -1
View File
@@ -26,9 +26,11 @@ class Adapters {
this.postQuery = new PostQuery({
postsDb: level.postsDb,
postHeightsDb: level.postHeightsDb,
addrPostHeightsDb: level.addrPostHeightsDb,
postParentsDb: level.postParentsDb,
postChildrenDb: level.postChildrenDb,
likesDb: level.likesDb
likesDb: level.likesDb,
postLikesDb: level.postLikesDb
})
this.followQuery = new FollowQuery({
followsDb: level.followsDb
+2
View File
@@ -13,9 +13,11 @@ const DB_NAMES = [
'status',
'posts',
'postHeights',
'addrPostHeights',
'postParents',
'postChildren',
'likes',
'postLikes',
'names',
'profiles',
'profilePics',
+132 -19
View File
@@ -1,18 +1,25 @@
/*
Adapter for efficient post queries using a postHeights secondary index.
Adapter for efficient post queries using secondary indexes.
- postHeights: global top-level post ordering by block height
- addrPostHeights: posts by address ordered by block height
- postLikes: likes grouped by liked post txid
*/
const HEIGHT_PAD = 12
class PostQuery {
constructor (localConfig = {}) {
const { postsDb, postHeightsDb, postParentsDb, postChildrenDb, likesDb } = localConfig
const { postsDb, postHeightsDb, addrPostHeightsDb, postParentsDb, postChildrenDb, likesDb, postLikesDb } = localConfig
if (!postsDb) {
throw new Error('postsDb required when instantiating PostQuery adapter.')
}
if (!postHeightsDb) {
throw new Error('postHeightsDb required when instantiating PostQuery adapter.')
}
if (!addrPostHeightsDb) {
throw new Error('addrPostHeightsDb required when instantiating PostQuery adapter.')
}
if (!postParentsDb) {
throw new Error('postParentsDb required when instantiating PostQuery adapter.')
}
@@ -22,23 +29,31 @@ class PostQuery {
if (!likesDb) {
throw new Error('likesDb required when instantiating PostQuery adapter.')
}
if (!postLikesDb) {
throw new Error('postLikesDb required when instantiating PostQuery adapter.')
}
this.postsDb = postsDb
this.postHeightsDb = postHeightsDb
this.addrPostHeightsDb = addrPostHeightsDb
this.postParentsDb = postParentsDb
this.postChildrenDb = postChildrenDb
this.likesDb = likesDb
this.postLikesDb = postLikesDb
this.scanRecentPostTxids = this.scanRecentPostTxids.bind(this)
this.scanPostsByAddrTxids = this.scanPostsByAddrTxids.bind(this)
this.loadPostsByTxids = this.loadPostsByTxids.bind(this)
this.countTopLevelPosts = this.countTopLevelPosts.bind(this)
this.countTopLevelPostsByAddr = this.countTopLevelPostsByAddr.bind(this)
this.loadReplyTxids = this.loadReplyTxids.bind(this)
this.buildReplyCountMap = this.buildReplyCountMap.bind(this)
this.countRepliesForTxids = this.countRepliesForTxids.bind(this)
this.countLikesForTxids = this.countLikesForTxids.bind(this)
this.buildLikeCountMap = this.buildLikeCountMap.bind(this)
this.txidFromPostHeight = this.txidFromPostHeight.bind(this)
this.txidFromAddrPostHeight = this.txidFromAddrPostHeight.bind(this)
this.getPostOrNull = this.getPostOrNull.bind(this)
this.topLevelPostTxids = this.topLevelPostTxids.bind(this)
this.loadReplyTxids = this.loadReplyTxids.bind(this)
this.isReply = this.isReply.bind(this)
}
static padHeight (height) {
@@ -49,12 +64,26 @@ class PostQuery {
return `${PostQuery.padHeight(blockHeight)}:${txid}`
}
static addrPostHeightKey (addr, blockHeight, txid) {
return `${addr}:${PostQuery.padHeight(blockHeight)}:${txid}`
}
static postLikeKey (postTxid, likeTxid) {
return `${postTxid}:${likeTxid}`
}
txidFromPostHeight (key, value) {
if (value && typeof value.txid === 'string') return value.txid
const parts = String(key).split(':')
return parts[parts.length - 1]
}
txidFromAddrPostHeight (key, value) {
if (value && typeof value.txid === 'string') return value.txid
const parts = String(key).split(':')
return parts[parts.length - 1]
}
async loadReplyTxids () {
const replyTxids = new Set()
@@ -65,6 +94,34 @@ class PostQuery {
return replyTxids
}
async isReply (txid) {
try {
await this.postParentsDb.get(txid)
return true
} catch (err) {
if (err.notFound || err.code === 'LEVEL_NOT_FOUND') return false
throw err
}
}
// Count replies for each txid by prefix-scanning postChildren.
async countRepliesForTxids (txids) {
const counts = new Map()
const end = ':\uffff'
for (const txid of txids) {
const prefix = `${txid}:`
let count = 0
for await (const [, child] of this.postChildrenDb.iterator({ gte: prefix, lte: `${txid}${end}` })) {
if (child?.parentTxid === txid) count++
}
counts.set(txid, count)
}
return counts
}
// Build a global reply-count map by scanning all postChildren entries.
async buildReplyCountMap () {
const counts = new Map()
@@ -77,11 +134,38 @@ class PostQuery {
return counts
}
// Count likes for each txid by prefix-scanning postLikes.
async countLikesForTxids (txids) {
const counts = new Map()
const end = ':\uffff'
for (const txid of txids) {
const prefix = `${txid}:`
let count = 0
for await (const [key, value] of this.postLikesDb.iterator({ gte: prefix, lte: `${txid}${end}` })) {
const likeTxid = this.likeTxidFromPostLike(key, value)
if (likeTxid) count++
}
counts.set(txid, count)
}
return counts
}
likeTxidFromPostLike (key, value) {
if (value && typeof value.likeTxid === 'string') return value.likeTxid
if (value && typeof value.txid === 'string') return value.txid
const parts = String(key).split(':')
return parts[parts.length - 1]
}
// Build a global like-count map from the postLikes secondary index,
// ignoring likes whose target post no longer exists.
async buildLikeCountMap () {
const counts = new Map()
for await (const [, like] of this.likesDb.iterator()) {
const postTxid = like?.postTxid
for await (const [key, value] of this.postLikesDb.iterator()) {
const postTxid = this.postTxidFromPostLike(key, value)
if (!postTxid) continue
const post = await this.getPostOrNull(postTxid)
if (!post) continue
@@ -91,6 +175,12 @@ class PostQuery {
return counts
}
postTxidFromPostLike (key, value) {
if (value && typeof value.postTxid === 'string') return value.postTxid
const parts = String(key).split(':')
return parts[0]
}
// Fetch a post by txid, returning null when the post is not found.
async getPostOrNull (txid) {
try {
@@ -130,23 +220,43 @@ class PostQuery {
return txids
}
async scanPostsByAddrTxids (addr, { limit, offset }) {
// Iterate posts for a single address using the addrPostHeights index,
// newest first, skipping replies (which have their own listing path).
// Returns both the page txids and the total top-level count for the address
// so the caller can compute pagination with a single index scan.
async scanPostsByAddrTxidsAndCount (addr, { limit, offset }) {
const txids = []
let skipped = 0
let total = 0
const start = `${addr}:`
const end = `${addr}:\uffff`
for await (const txid of this.topLevelPostTxids({ reverse: true })) {
const post = await this.getPostOrNull(txid)
if (!post || post.addr !== addr) continue
for await (const [key, value] of this.addrPostHeightsDb.iterator({
gte: start,
lte: end,
reverse: true
})) {
const txid = this.txidFromAddrPostHeight(key, value)
if (await this.isReply(txid)) continue
total++
if (skipped < offset) {
skipped++
continue
}
txids.push(txid)
if (txids.length >= limit) break
if (txids.length < limit) {
txids.push(txid)
}
}
return { txids, total }
}
// Backwards-compatible variant that returns only txids.
async scanPostsByAddrTxids (addr, { limit, offset }) {
const { txids } = await this.scanPostsByAddrTxidsAndCount(addr, { limit, offset })
return txids
}
@@ -181,12 +291,19 @@ class PostQuery {
return count
}
// Count top-level posts for an address using the addrPostHeights index.
async countTopLevelPostsByAddr (addr) {
const replyTxids = await this.loadReplyTxids()
let count = 0
const start = `${addr}:`
const end = `${addr}:\uffff`
for await (const txid of this.topLevelPostTxids()) {
const post = await this.getPostOrNull(txid)
if (post && post.addr === addr) count++
for await (const [key, value] of this.addrPostHeightsDb.iterator({
gte: start,
lte: end
})) {
const txid = this.txidFromAddrPostHeight(key, value)
if (!replyTxids.has(txid)) count++
}
return count
@@ -194,7 +311,3 @@ class PostQuery {
}
export default PostQuery
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-27T03:28:06.470Z","module_hash":"62649e78ba195018e963925a0e47cb5915bba05499c3b04553eecceb37ef813a","functions":[{"id":"func/PostQuery.constructor","name":"PostQuery.constructor","line":8,"end_line":42,"hash":"3e32c5868d6bc9ea39cc8d7e87f646a2dc7d03663d7cd54029200b754afe27b7"},{"id":"func/PostQuery.padHeight","name":"PostQuery.padHeight","line":44,"end_line":46,"hash":"be6c442a4d3d86ab3b60314756b7f7c0592479c21cb3b2e1273dcf139a84fb00"},{"id":"func/PostQuery.postHeightKey","name":"PostQuery.postHeightKey","line":48,"end_line":50,"hash":"2d4dff9464aa4c1e805da5de2ba314fbd856530c046705237ea848d5feff8c7c"},{"id":"func/PostQuery.txidFromPostHeight","name":"PostQuery.txidFromPostHeight","line":52,"end_line":56,"hash":"691bcf6f1ab70e0608e3f05da9b9f7d88cc8ac710cdcb14e6dc4a1c1c0546744"},{"id":"func/PostQuery.loadReplyTxids","name":"PostQuery.loadReplyTxids","line":58,"end_line":66,"hash":"a397af5257d234a2aa9c18b1de49aa3738bf31645e0efbb9ed71bd0940abdb18"},{"id":"func/PostQuery.buildReplyCountMap","name":"PostQuery.buildReplyCountMap","line":68,"end_line":78,"hash":"1d9762d70dca3439c0bb382b09882faee215f1d53f0656aff8bcbde429ec63b4"},{"id":"func/PostQuery.buildLikeCountMap","name":"PostQuery.buildLikeCountMap","line":80,"end_line":92,"hash":"ff88d216af5734cd7b1eab31b87e0b412c2cac15eb7e6962465827a0ecaf3e4d"},{"id":"func/PostQuery.getPostOrNull","name":"PostQuery.getPostOrNull","line":95,"end_line":102,"hash":"792ce2a8d5f19ed3d159c7af7e95c310e5b0c05cbef4de5be8f8f78403680b91"},{"id":"func/PostQuery.topLevelPostTxids","name":"PostQuery.topLevelPostTxids","line":106,"end_line":114,"hash":"0457d8b43b692d7bbfde283e63663d74542778d3893b45202fcb6ae0f1fc6776"},{"id":"func/PostQuery.scanRecentPostTxids","name":"PostQuery.scanRecentPostTxids","line":116,"end_line":131,"hash":"bed887c0eeec051e082657cac518bbd2f60df84bbb86c9d8aa76015194e7a73a"},{"id":"func/PostQuery.scanPostsByAddrTxids","name":"PostQuery.scanPostsByAddrTxids","line":133,"end_line":151,"hash":"6485e255e529be0172debb67749fc2fc1757e5c41f6d03fd14f8d277aea4b91a"},{"id":"func/PostQuery.loadPostsByTxids","name":"PostQuery.loadPostsByTxids","line":153,"end_line":169,"hash":"86b05107f3ecc240b2e734217cedf29ef44c48474bf31c1c8f75f2e4b4f84bea"},{"id":"func/PostQuery.countTopLevelPosts","name":"PostQuery.countTopLevelPosts","line":171,"end_line":182,"hash":"a26a6fdd12201de71965545f4113e3598f325fa4d96076289d371e4157c667ff"},{"id":"func/PostQuery.countTopLevelPostsByAddr","name":"PostQuery.countTopLevelPostsByAddr","line":184,"end_line":193,"hash":"d5bcd0c7140f6c05cc3e66037ca96627ff262dd975a5553483bbd8f91bfdd7f5"}]}
// mutate4javascript-manifest-end
@@ -37,9 +37,11 @@ export function makeCrudHandlers ({ dbProp, keyParam, bodyIdField, bodyDataField
export const ENTITY_CONFIG = [
{ route: 'post', dbProp: 'postsDb', keyParam: 'txid', bodyIdField: 'txid', bodyDataField: 'postData' },
{ route: 'postheight', dbProp: 'postHeightsDb', keyParam: 'key', bodyIdField: 'key', bodyDataField: 'postHeightData' },
{ route: 'addrpostheight', dbProp: 'addrPostHeightsDb', keyParam: 'key', bodyIdField: 'key', bodyDataField: 'addrPostHeightData' },
{ route: 'postparent', dbProp: 'postParentsDb', keyParam: 'txid', bodyIdField: 'txid', bodyDataField: 'parentData' },
{ route: 'postchild', dbProp: 'postChildrenDb', keyParam: 'key', bodyIdField: 'key', bodyDataField: 'childData' },
{ route: 'like', dbProp: 'likesDb', keyParam: 'txid', bodyIdField: 'txid', bodyDataField: 'likeData' },
{ route: 'postlike', dbProp: 'postLikesDb', keyParam: 'key', bodyIdField: 'key', bodyDataField: 'postLikeData' },
{ 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' },
@@ -25,12 +25,11 @@ class ListPostsByAddr extends ListUseCase {
const limit = parseLimit(inObj.limit)
const offset = parseOffset(inObj.offset)
const txids = await this.adapters.postQuery.scanPostsByAddrTxids(addr, { limit, offset })
const [posts, replyCounts, likeCounts, total] = await Promise.all([
const { txids, total } = await this.adapters.postQuery.scanPostsByAddrTxidsAndCount(addr, { limit, offset })
const [posts, replyCounts, likeCounts] = await Promise.all([
this.adapters.postQuery.loadPostsByTxids(txids),
this.adapters.postQuery.buildReplyCountMap(),
this.adapters.postQuery.buildLikeCountMap(),
this.adapters.postQuery.countTopLevelPostsByAddr(addr)
this.adapters.postQuery.countRepliesForTxids(txids),
this.adapters.postQuery.countLikesForTxids(txids)
])
return assemblePostPage({ posts, replyCounts, likeCounts, total, limit, offset })
@@ -19,7 +19,7 @@ class ListRecentPosts extends ListUseCase {
const [posts, replyCounts, likeCounts, total] = await Promise.all([
this.adapters.postQuery.loadPostsByTxids(txids),
this.adapters.postQuery.buildReplyCountMap(),
this.adapters.postQuery.buildLikeCountMap(),
this.adapters.postQuery.countLikesForTxids(txids),
this.adapters.postQuery.countTopLevelPosts()
])
@@ -33,12 +33,12 @@ function mockPostsDb (posts) {
}
}
// A likes store that iterates [key, like] pairs.
function mockLikesDb (likes) {
// A postLikes store that iterates [key, postLike] pairs keyed as postTxid:likeTxid.
function mockPostLikesDb (likes) {
return {
async * iterator () {
for (const like of likes) {
yield [like.txid, like]
yield [`${like.postTxid}:${like.txid}`, like]
}
}
}
@@ -47,10 +47,12 @@ function mockLikesDb (likes) {
function makeQuery (posts, likes) {
return new PostQuery({
postsDb: mockPostsDb(posts),
likesDb: mockLikesDb(likes),
postLikesDb: mockPostLikesDb(likes),
postHeightsDb: {},
addrPostHeightsDb: {},
postParentsDb: {},
postChildrenDb: {}
postChildrenDb: {},
likesDb: {}
})
}
@@ -22,9 +22,11 @@ test('postHeightKey round-trips the txid for a broad range of heights', async ()
const query = new PostQuery({
postsDb: {},
postHeightsDb: {},
addrPostHeightsDb: {},
postParentsDb: {},
postChildrenDb: {},
likesDb: {}
likesDb: {},
postLikesDb: {}
})
await forAll(
+158 -105
View File
@@ -6,11 +6,12 @@ describe('#PostQuery', () => {
let uut
let sandbox
let postsDb
let postHeightsDb
let addrPostHeightsDb
let postParentsDb
let postChildrenDb
let postHeightsDb
let likesDb
let postLikesDb
beforeEach(() => {
sandbox = sinon.createSandbox()
@@ -18,29 +19,43 @@ describe('#PostQuery', () => {
iterator: sandbox.stub(),
get: sandbox.stub()
}
postParentsDb = {
postHeightsDb = {
iterator: sandbox.stub()
}
addrPostHeightsDb = {
iterator: sandbox.stub()
}
postParentsDb = {
iterator: sandbox.stub(),
get: sandbox.stub()
}
postChildrenDb = {
iterator: sandbox.stub()
}
postHeightsDb = {
iterator: sandbox.stub()
}
likesDb = {
iterator: sandbox.stub()
}
postLikesDb = {
iterator: sandbox.stub()
}
async function * emptyParents () {}
async function * emptyChildren () {}
async function * emptyHeights () {}
async function * emptyLikes () {}
postParentsDb.iterator.returns(emptyParents())
postChildrenDb.iterator.returns(emptyChildren())
postHeightsDb.iterator.returns(emptyHeights())
likesDb.iterator.returns(emptyLikes())
async function * empty () {}
postHeightsDb.iterator.returns(empty())
addrPostHeightsDb.iterator.returns(empty())
postParentsDb.iterator.returns(empty())
postChildrenDb.iterator.returns(empty())
likesDb.iterator.returns(empty())
postLikesDb.iterator.returns(empty())
uut = new PostQuery({ postsDb, postParentsDb, postChildrenDb, postHeightsDb, likesDb })
uut = new PostQuery({
postsDb,
postHeightsDb,
addrPostHeightsDb,
postParentsDb,
postChildrenDb,
likesDb,
postLikesDb
})
})
afterEach(() => sandbox.restore())
@@ -48,20 +63,30 @@ describe('#PostQuery', () => {
it('should throw when postHeightsDb is missing', () => {
try {
// eslint-disable-next-line no-new
new PostQuery({ postsDb, postParentsDb, postChildrenDb, likesDb })
new PostQuery({ postsDb, postParentsDb, postChildrenDb, likesDb, postLikesDb })
assert.fail('Expected error')
} catch (err) {
assert.include(err.message, 'postHeightsDb required')
}
})
it('should throw when likesDb is missing', () => {
it('should throw when addrPostHeightsDb is missing', () => {
try {
// eslint-disable-next-line no-new
new PostQuery({ postsDb, postHeightsDb, postParentsDb, postChildrenDb })
new PostQuery({ postsDb, postHeightsDb, postParentsDb, postChildrenDb, likesDb, postLikesDb })
assert.fail('Expected error')
} catch (err) {
assert.include(err.message, 'likesDb required')
assert.include(err.message, 'addrPostHeightsDb required')
}
})
it('should throw when postLikesDb is missing', () => {
try {
// eslint-disable-next-line no-new
new PostQuery({ postsDb, postHeightsDb, addrPostHeightsDb, postParentsDb, postChildrenDb, likesDb })
assert.fail('Expected error')
} catch (err) {
assert.include(err.message, 'postLikesDb required')
}
})
@@ -151,61 +176,60 @@ describe('#PostQuery', () => {
})
describe('#scanPostsByAddrTxids', () => {
it('should return txids for the address sorted by block height descending', async () => {
async function * mockHeights () {
yield ['000000600200:post-200-b', { txid: 'post-200-b' }]
yield ['000000600200:post-200-a', { txid: 'post-200-a' }]
yield ['000000600100:post-100', { txid: 'post-100' }]
}
postHeightsDb.iterator.withArgs({ reverse: true }).returns(mockHeights())
postsDb.get.callsFake(async (txid) => {
const posts = {
'post-200-b': { addr: 'addr-b', text: 'b', seen: 200, blockHeight: 600200 },
'post-200-a': { addr: 'addr-a', text: 'a', seen: 100, blockHeight: 600200 },
'post-100': { addr: 'addr-a', text: 'c', seen: 50, blockHeight: 600100 }
}
return posts[txid]
})
beforeEach(() => {
const err = new Error('not found')
err.notFound = true
postParentsDb.get.rejects(err)
})
const result = await uut.scanPostsByAddrTxids('addr-a', { limit: 2, offset: 0 })
it('should return txids for the address sorted by block height descending', async () => {
async function * mockAddrHeights () {
yield ['bitcoincash:qaddr-a:000000600200:post-200-a', { txid: 'post-200-a' }]
yield ['bitcoincash:qaddr-a:000000600100:post-100', { txid: 'post-100' }]
}
addrPostHeightsDb.iterator
.withArgs({ gte: 'bitcoincash:qaddr-a:', lte: 'bitcoincash:qaddr-a:\uffff', reverse: true })
.returns(mockAddrHeights())
const result = await uut.scanPostsByAddrTxids('bitcoincash:qaddr-a', { limit: 2, offset: 0 })
assert.deepEqual(result, ['post-200-a', 'post-100'])
})
it('should skip replies when selecting posts by address', async () => {
async function * mockAddrHeights () {
yield ['bitcoincash:qaddr-a:000000600200:post-200-a', { txid: 'post-200-a' }]
yield ['bitcoincash:qaddr-a:000000600100:post-100', { txid: 'post-100' }]
yield ['bitcoincash:qaddr-a:000000600050:reply-1', { txid: 'reply-1' }]
}
postParentsDb.get.callsFake(async (txid) => {
if (txid === 'reply-1') return { parentTxid: 'post-200-a', childTxid: 'reply-1', blockHeight: 600050 }
const err = new Error('not found')
err.notFound = true
throw err
})
addrPostHeightsDb.iterator
.withArgs({ gte: 'bitcoincash:qaddr-a:', lte: 'bitcoincash:qaddr-a:\uffff', reverse: true })
.returns(mockAddrHeights())
const result = await uut.scanPostsByAddrTxids('bitcoincash:qaddr-a', { limit: 2, offset: 0 })
assert.deepEqual(result, ['post-200-a', 'post-100'])
})
it('should apply offset and limit for the address', async () => {
async function * mockHeights () {
yield ['000000600200:post-200-b', { txid: 'post-200-b' }]
yield ['000000600200:post-200-a', { txid: 'post-200-a' }]
yield ['000000600100:post-100', { txid: 'post-100' }]
async function * mockAddrHeights () {
yield ['bitcoincash:qaddr-a:000000600200:post-200-a', { txid: 'post-200-a' }]
yield ['bitcoincash:qaddr-a:000000600100:post-100', { txid: 'post-100' }]
}
postHeightsDb.iterator.withArgs({ reverse: true }).returns(mockHeights())
postsDb.get.callsFake(async (txid) => {
const posts = {
'post-200-b': { addr: 'addr-b', text: 'b', seen: 200, blockHeight: 600200 },
'post-200-a': { addr: 'addr-a', text: 'a', seen: 100, blockHeight: 600200 },
'post-100': { addr: 'addr-a', text: 'c', seen: 50, blockHeight: 600100 }
}
return posts[txid]
})
addrPostHeightsDb.iterator
.withArgs({ gte: 'bitcoincash:qaddr-a:', lte: 'bitcoincash:qaddr-a:\uffff', reverse: true })
.returns(mockAddrHeights())
const result = await uut.scanPostsByAddrTxids('addr-a', { limit: 1, offset: 1 })
const result = await uut.scanPostsByAddrTxids('bitcoincash:qaddr-a', { limit: 1, offset: 1 })
assert.deepEqual(result, ['post-100'])
})
it('should stop at limit even when more matching posts remain', async () => {
async function * mockHeights () {
yield ['000000600300:post-300', { txid: 'post-300' }]
yield ['000000600200:post-200', { txid: 'post-200' }]
yield ['000000600100:post-100', { txid: 'post-100' }]
}
postHeightsDb.iterator.withArgs({ reverse: true }).returns(mockHeights())
postsDb.get.callsFake(async (txid) => ({ addr: 'addr-a', text: 'x', seen: 1, blockHeight: 1 }))
const result = await uut.scanPostsByAddrTxids('addr-a', { limit: 2, offset: 0 })
assert.deepEqual(result, ['post-300', 'post-200'])
})
})
describe('#loadPostsByTxids', () => {
@@ -264,27 +288,47 @@ describe('#PostQuery', () => {
describe('#countTopLevelPostsByAddr', () => {
it('should count top-level posts for an address', async () => {
async function * mockHeights () {
yield ['000000600200:post-200-b', { txid: 'post-200-b' }]
yield ['000000600200:post-200-a', { txid: 'post-200-a' }]
yield ['000000600100:post-100', { txid: 'post-100' }]
async function * mockAddrHeights () {
yield ['bitcoincash:qaddr-a:000000600200:post-200-a', { txid: 'post-200-a' }]
yield ['bitcoincash:qaddr-a:000000600100:post-100', { txid: 'post-100' }]
}
postHeightsDb.iterator.returns(mockHeights())
postsDb.get.callsFake(async (txid) => {
const posts = {
'post-200-b': { addr: 'addr-b', text: 'b', seen: 200, blockHeight: 600200 },
'post-200-a': { addr: 'addr-a', text: 'a', seen: 100, blockHeight: 600200 },
'post-100': { addr: 'addr-a', text: 'c', seen: 50, blockHeight: 600100 }
}
return posts[txid]
})
async function * mockParents () {
yield ['reply-1', { parentTxid: 'post-200-a', childTxid: 'reply-1', blockHeight: 600050 }]
}
addrPostHeightsDb.iterator
.withArgs({ gte: 'bitcoincash:qaddr-a:', lte: 'bitcoincash:qaddr-a:\uffff' })
.returns(mockAddrHeights())
postParentsDb.iterator.returns(mockParents())
const result = await uut.countTopLevelPostsByAddr('addr-a')
const result = await uut.countTopLevelPostsByAddr('bitcoincash:qaddr-a')
assert.equal(result, 2)
})
})
describe('#countRepliesForTxids', () => {
it('should count replies per txid from postChildren', async () => {
async function * mockChildrenTx1 () {
yield ['tx1:reply-a', { parentTxid: 'tx1', childTxid: 'reply-a', blockHeight: 600150 }]
yield ['tx1:reply-b', { parentTxid: 'tx1', childTxid: 'reply-b', blockHeight: 600160 }]
}
async function * mockChildrenTx2 () {
yield ['tx2:reply-c', { parentTxid: 'tx2', childTxid: 'reply-c', blockHeight: 600170 }]
}
postChildrenDb.iterator
.withArgs(sinon.match({ gte: 'tx1:', lte: 'tx1:\uffff' }))
.returns(mockChildrenTx1())
postChildrenDb.iterator
.withArgs(sinon.match({ gte: 'tx2:', lte: 'tx2:\uffff' }))
.returns(mockChildrenTx2())
const result = await uut.countRepliesForTxids(['tx1', 'tx2'])
assert.equal(result.get('tx1'), 2)
assert.equal(result.get('tx2'), 1)
})
})
describe('#buildReplyCountMap', () => {
it('should count replies per parent from postChildren', async () => {
async function * mockChildren () {
@@ -301,43 +345,52 @@ describe('#PostQuery', () => {
})
})
describe('#buildLikeCountMap', () => {
it('should count likes per post, ignoring likes for missing posts', async () => {
async function * mockLikes () {
yield ['like-a', { postTxid: 'tx1', addr: 'addr-x', seen: 1, blockHeight: 1 }]
yield ['like-b', { postTxid: 'tx1', addr: 'addr-y', seen: 2, blockHeight: 2 }]
yield ['like-c', { postTxid: 'tx2', addr: 'addr-z', seen: 3, blockHeight: 3 }]
yield ['like-d', { postTxid: 'missing', addr: 'addr-w', seen: 4, blockHeight: 4 }]
describe('#countLikesForTxids', () => {
it('should count likes per txid from postLikes', async () => {
async function * mockPostLikesTx1 () {
yield ['tx1:like-a', { postTxid: 'tx1', txid: 'like-a' }]
yield ['tx1:like-b', { postTxid: 'tx1', txid: 'like-b' }]
}
likesDb.iterator.returns(mockLikes())
async function * mockPostLikesTx2 () {
yield ['tx2:like-c', { postTxid: 'tx2', txid: 'like-c' }]
}
postLikesDb.iterator
.withArgs(sinon.match({ gte: 'tx1:', lte: 'tx1:\uffff' }))
.returns(mockPostLikesTx1())
postLikesDb.iterator
.withArgs(sinon.match({ gte: 'tx2:', lte: 'tx2:\uffff' }))
.returns(mockPostLikesTx2())
const result = await uut.countLikesForTxids(['tx1', 'tx2'])
assert.equal(result.get('tx1'), 2)
assert.equal(result.get('tx2'), 1)
})
})
describe('#buildLikeCountMap', () => {
it('should count likes per post from the postLikes index', async () => {
async function * mockPostLikes () {
yield ['tx1:like-a', { postTxid: 'tx1', txid: 'like-a' }]
yield ['tx1:like-b', { postTxid: 'tx1', txid: 'like-b' }]
yield ['tx2:like-c', { postTxid: 'tx2', txid: 'like-c' }]
yield ['tx3:like-d', {}]
}
postLikesDb.iterator.returns(mockPostLikes())
postsDb.get.callsFake(async (txid) => {
if (txid === 'missing') {
const err = new Error('not found')
err.notFound = true
throw err
if (txid === 'tx1' || txid === 'tx2' || txid === 'tx3') {
return { addr: 'addr-a', text: 'x', seen: 1, blockHeight: 1 }
}
return { addr: 'addr-a', text: 'x', seen: 1, blockHeight: 1 }
const err = new Error('not found')
err.notFound = true
throw err
})
const result = await uut.buildLikeCountMap()
assert.equal(result.get('tx1'), 2)
assert.equal(result.get('tx2'), 1)
assert.equal(result.has('missing'), false)
})
it('should skip like records without a postTxid', async () => {
async function * mockLikes () {
yield ['like-a', { postTxid: 'tx1' }]
yield ['like-b', { addr: 'addr-x' }]
}
likesDb.iterator.returns(mockLikes())
postsDb.get.withArgs('tx1').resolves({ addr: 'addr-a', text: 'x', seen: 1, blockHeight: 1 })
const result = await uut.buildLikeCountMap()
assert.equal(result.get('tx1'), 1)
assert.equal(result.size, 1)
assert.equal(result.get('tx3'), 1)
})
})
})
@@ -16,19 +16,21 @@ describe('#ListPostsByAddr', () => {
beforeEach(() => {
sandbox = sinon.createSandbox()
postQuery = {
scanPostsByAddrTxids: sandbox.stub().callsFake(async (addr, { limit, offset }) => {
scanPostsByAddrTxidsAndCount: sandbox.stub().callsFake(async (addr, { limit, offset }) => {
const all = Object.entries(mockPosts)
.filter(([txid, post]) => post.addr === addr)
.sort((a, b) => b[1].blockHeight - a[1].blockHeight)
.map(([txid]) => txid)
return all.slice(offset, offset + limit)
return {
txids: all.slice(offset, offset + limit),
total: all.length
}
}),
loadPostsByTxids: sandbox.stub().callsFake(async (txids) => {
return txids.map((txid) => ({ txid, ...mockPosts[txid] }))
}),
buildReplyCountMap: sandbox.stub().resolves(new Map()),
buildLikeCountMap: sandbox.stub().resolves(new Map([['tx-c', 7]])),
countTopLevelPostsByAddr: sandbox.stub().resolves(2)
countRepliesForTxids: sandbox.stub().resolves(new Map()),
countLikesForTxids: sandbox.stub().resolves(new Map([['tx-c', 7]]))
}
uut = new ListPostsByAddr({
adapters: { postQuery }
@@ -52,11 +54,15 @@ describe('#ListPostsByAddr', () => {
it('should report hasMore when a further page exists', async () => {
// One page of one item still leaves one more page available.
postQuery.scanPostsByAddrTxids.callsFake(async (addr, { limit, offset }) => {
return Object.entries(mockPosts)
postQuery.scanPostsByAddrTxidsAndCount.callsFake(async (addr, { limit, offset }) => {
const all = Object.entries(mockPosts)
.filter(([txid, post]) => post.addr === addr)
.map(([txid]) => txid)
.slice(offset, offset + limit)
.sort((a, b) => mockPosts[b].blockHeight - mockPosts[a].blockHeight)
return {
txids: all.slice(offset, offset + limit),
total: all.length
}
})
const result = await uut.execute({ addr: 'addr-a', limit: 1, offset: 0 })
@@ -87,8 +93,8 @@ describe('#ListPostsByAddr', () => {
it('should pass addr, limit, and offset to postQuery', async () => {
await uut.execute({ addr: 'addr-a', limit: 5, offset: 10 })
assert.equal(postQuery.scanPostsByAddrTxids.calledOnce, true)
assert.equal(postQuery.scanPostsByAddrTxids.firstCall.args[0], 'addr-a')
assert.deepEqual(postQuery.scanPostsByAddrTxids.firstCall.args[1], { limit: 5, offset: 10 })
assert.equal(postQuery.scanPostsByAddrTxidsAndCount.calledOnce, true)
assert.equal(postQuery.scanPostsByAddrTxidsAndCount.firstCall.args[0], 'addr-a')
assert.deepEqual(postQuery.scanPostsByAddrTxidsAndCount.firstCall.args[1], { limit: 5, offset: 10 })
})
})
@@ -21,7 +21,7 @@ describe('#ListRecentPosts', () => {
return txids.map((txid) => ({ txid, ...mockPosts[txid] }))
}),
buildReplyCountMap: sandbox.stub().resolves(new Map([['tx-b', 1]])),
buildLikeCountMap: sandbox.stub().resolves(new Map([['tx-b', 3], ['tx-a', 5]])),
countLikesForTxids: sandbox.stub().resolves(new Map([['tx-b', 3], ['tx-a', 5]])),
countTopLevelPosts: sandbox.stub().resolves(3)
}
uut = new ListRecentPosts({
@@ -0,0 +1,147 @@
/*
Utility: backfill the addrPostHeights and postLikes secondary indexes for an
existing psf-memo-db.
New deployments write these indexes while indexing live blocks, but existing
databases that were populated before the efficient-query feature need a
one-time backfill.
Run from the psf-memo-db repo root on the host that owns the LevelDB files:
node util/post/backfill-post-indexes.js
The script is idempotent: re-running it will not create duplicate index
entries. 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-index-backup
*/
import level from 'level'
import * as fs from 'fs'
import * as path from 'path'
import * as url from 'url'
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')
const HEIGHT_PAD = 12
const PROGRESS_INTERVAL = 10000
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 posts and likes stores.`)
}
return storePath
}
function addrPostHeightKey (addr, blockHeight, txid) {
const padded = String(blockHeight).padStart(HEIGHT_PAD, '0')
return `${addr}:${padded}:${txid}`
}
function postLikeKey (postTxid, likeTxid) {
return `${postTxid}:${likeTxid}`
}
async function createIfMissing (db, key, value) {
try {
await db.get(key)
return false
} catch (err) {
if (err.notFound || err.code === 'LEVEL_NOT_FOUND') {
await db.put(key, value)
return true
}
throw err
}
}
async function backfill () {
console.error(`Using LevelDB data directory: ${DATA_DIR}`)
const postsPath = requiredStorePath(DATA_DIR, 'posts')
const likesPath = requiredStorePath(DATA_DIR, 'likes')
const addrPostHeightsPath = path.join(DATA_DIR, 'addrPostHeights')
const postLikesPath = path.join(DATA_DIR, 'postLikes')
console.error('Opening LevelDB stores...')
const postsDb = level(postsPath, { valueEncoding: 'json' })
const likesDb = level(likesPath, { valueEncoding: 'json' })
const addrPostHeightsDb = level(addrPostHeightsPath, { valueEncoding: 'json', createIfMissing: true })
const postLikesDb = level(postLikesPath, { valueEncoding: 'json', createIfMissing: true })
try {
console.error('Backfilling addrPostHeights entries from posts...')
let postsScanned = 0
let addrPostHeightsCreated = 0
let addrPostHeightsExisting = 0
for await (const [txid, post] of postsDb.iterator()) {
postsScanned++
const addr = post.addr
const blockHeight = post.blockHeight ?? 0
const key = addrPostHeightKey(addr, blockHeight, txid)
const value = { txid, addr, blockHeight }
const created = await createIfMissing(addrPostHeightsDb, key, value)
if (created) {
addrPostHeightsCreated++
} else {
addrPostHeightsExisting++
}
if (postsScanned % PROGRESS_INTERVAL === 0) {
console.error(` scanned ${postsScanned} posts, ${addrPostHeightsCreated} written, ${addrPostHeightsExisting} skipped...`)
}
}
console.error('\nBackfilling postLikes entries from likes...')
let likesScanned = 0
let postLikesCreated = 0
let postLikesExisting = 0
for await (const [txid, like] of likesDb.iterator()) {
likesScanned++
const postTxid = like.postTxid
if (!postTxid) continue
const key = postLikeKey(postTxid, txid)
const value = { postTxid, txid }
const created = await createIfMissing(postLikesDb, key, value)
if (created) {
postLikesCreated++
} else {
postLikesExisting++
}
if (likesScanned % PROGRESS_INTERVAL === 0) {
console.error(` scanned ${likesScanned} likes, ${postLikesCreated} written, ${postLikesExisting} skipped...`)
}
}
console.error('\nBackfill complete.')
console.error(` posts scanned: ${postsScanned}`)
console.error(` addrPostHeights created: ${addrPostHeightsCreated}`)
console.error(` addrPostHeights already present: ${addrPostHeightsExisting}`)
console.error(` likes scanned: ${likesScanned}`)
console.error(` postLikes created: ${postLikesCreated}`)
console.error(` postLikes already present: ${postLikesExisting}`)
} catch (err) {
console.error('\nBackfill failed:', err.message)
process.exitCode = 1
} finally {
await addrPostHeightsDb.close().catch(() => {})
await postLikesDb.close().catch(() => {})
await likesDb.close().catch(() => {})
await postsDb.close().catch(() => {})
}
}
backfill()
+81 -3
View File
@@ -1,14 +1,15 @@
/*
Project step handlers for the psf-memo-indexer acceptance pipeline.
These handlers exercise the real Memo action handlers (handlePost, handleReply)
against an in-memory database that exposes the same CRUD surface as the
psf-memo-db entity routes used by the indexer.
These handlers exercise the real Memo action handlers (handlePost, handleReply,
handleLike) against an in-memory database that exposes the same CRUD surface as
the psf-memo-db entity routes used by the indexer.
*/
import crypto from 'node:crypto'
import { handlePost } from '../../src/use-cases/action-types/post.js'
import { handleReply } from '../../src/use-cases/action-types/reply.js'
import { handleLike } from '../../src/use-cases/action-types/like.js'
function makeInMemoryDb () {
const store = new Map()
@@ -69,14 +70,20 @@ function resolveTxid (value, example, world) {
async function createWorld () {
const postsDb = makeInMemoryDb()
const postHeightsDb = makeInMemoryDb()
const addrPostHeightsDb = makeInMemoryDb()
const postParentsDb = makeInMemoryDb()
const postChildrenDb = makeInMemoryDb()
const likesDb = makeInMemoryDb()
const postLikesDb = makeInMemoryDb()
const adapters = {
postDb: postsDb,
postHeightDb: postHeightsDb,
addrPostHeightDb: addrPostHeightsDb,
postParentDb: postParentsDb,
postChildDb: postChildrenDb,
likeDb: likesDb,
postLikeDb: postLikesDb,
processErrorDb: makeInMemoryDb()
}
@@ -84,8 +91,11 @@ async function createWorld () {
adapters,
postsDb,
postHeightsDb,
addrPostHeightsDb,
postParentsDb,
postChildrenDb,
likesDb,
postLikesDb,
txidMap: new Map(),
lastTxid: null,
lastHeight: null,
@@ -101,6 +111,13 @@ const handlers = [
// World is already created with both stores.
}
},
{
name: 'db instance with new indexes',
pattern: /^a psf-memo-db instance with posts, postHeights, addrPostHeights, likes, and postLikes stores$/,
async run () {
// World is already created with all stores.
}
},
{
name: 'indexer configured to write to db',
pattern: /^a psf-memo-indexer configured to write to that database$/,
@@ -172,6 +189,37 @@ const handlers = [
})
}
},
{
name: 'process a Memo like transaction',
pattern: /^the indexer processes a Memo like transaction (.+) for post (.+) from (.+) at block height (.+)$/,
async run (m, example, world) {
const txid = resolveTxid(m[1], example, world)
const postTxid = resolveTxid(m[2], example, world)
const addr = resolveParam(m[3], example)
const height = parseInt(resolveParam(m[4], example), 10)
world.lastTxid = txid
world.lastHeight = height
world.lastAddr = addr
const prefix = Buffer.from('6d04', 'hex')
const postHash = Buffer.from(postTxid, 'hex').reverse()
await handleLike({
adapters: world.adapters,
txid,
signerAddr: addr,
seen: Date.now(),
blockHeight: height,
txDetails: { vout: [] },
decoded: {
action: 'like',
prefix,
pushDatas: [prefix, postHash]
}
})
}
},
{
name: 'process the same Memo post transaction again',
pattern: /^the indexer processes the same Memo post transaction (.+) again$/,
@@ -224,6 +272,21 @@ const handlers = [
}
}
},
{
name: 'addrPostHeights store contains entry',
pattern: /^the addrPostHeights store contains (.+) entry whose key starts with (.+) and ends with (.+)$/,
run (m, example, world) {
const expectedCount = parseInt(resolveParam(m[1], example), 10)
const addr = resolveParam(m[2], example)
const txid = resolveTxid(m[3], example, world)
const matching = world.addrPostHeightsDb.entries().filter(([key, value]) => {
return key.startsWith(`${addr}:`) && (key.endsWith(`:${txid}`) || value?.txid === txid)
})
if (matching.length !== expectedCount) {
throw new Error(`Expected ${expectedCount} addrPostHeights entry/entries for ${addr}/${txid}, got ${matching.length}`)
}
}
},
{
name: 'postParents store contains link',
pattern: /^the postParents store contains a link from (.+) to (.+)$/,
@@ -251,6 +314,21 @@ const handlers = [
throw new Error(`Expected postChildren link from ${parentTxid} to ${childTxid}`)
}
}
},
{
name: 'postLikes store contains entry',
pattern: /^the postLikes store contains (.+) entry whose key starts with (.+) and ends with (.+)$/,
run (m, example, world) {
const expectedCount = parseInt(resolveParam(m[1], example), 10)
const postTxid = resolveTxid(m[2], example, world)
const likeTxid = resolveTxid(m[3], example, world)
const matching = world.postLikesDb.entries().filter(([key, value]) => {
return key.startsWith(`${postTxid}:`) && (key.endsWith(`:${likeTxid}`) || value?.txid === likeTxid)
})
if (matching.length !== expectedCount) {
throw new Error(`Expected ${expectedCount} postLikes entry/entries for ${postTxid}/${likeTxid}, got ${matching.length}`)
}
}
}
]
@@ -21,9 +21,11 @@ class Adapters {
this.postDb = createEntityDb('post', 'txid', 'postData')
this.postHeightDb = createEntityDb('postheight', 'key', 'postHeightData')
this.addrPostHeightDb = createEntityDb('addrpostheight', 'key', 'addrPostHeightData')
this.postParentDb = createEntityDb('postparent', 'txid', 'parentData')
this.postChildDb = createEntityDb('postchild', 'key', 'childData')
this.likeDb = createEntityDb('like', 'txid', 'likeData')
this.postLikeDb = createEntityDb('postlike', 'key', 'postLikeData')
this.nameDb = createEntityDb('name', 'addr', 'nameData')
this.profileDb = createEntityDb('profile', 'addr', 'profileData')
this.profilePicDb = createEntityDb('profilepic', 'addr', 'profilePicData')
@@ -67,6 +67,15 @@ export function postHeightKey (blockHeight, txid) {
return `${padded}:${txid}`
}
export function addrPostHeightKey (addr, blockHeight, txid) {
const padded = String(blockHeight).padStart(12, '0')
return `${addr}:${padded}:${txid}`
}
export function postLikeKey (postTxid, likeTxid) {
return `${postTxid}:${likeTxid}`
}
export function postChildKey (parentTxid, childTxid) {
return `${parentTxid}:${childTxid}`
}
@@ -1,4 +1,4 @@
import { txHashFromPush, logProcessError } from './helpers.js'
import { txHashFromPush, logProcessError, postLikeKey } from './helpers.js'
import { TX_HASH_LENGTH } from '../../lib/memo-codes.js'
export async function handleLike (ctx) {
@@ -40,4 +40,5 @@ export async function handleLike (ctx) {
blockHeight
}
await adapters.likeDb.create(txid, likeData)
await adapters.postLikeDb.create(postLikeKey(postTxid, txid), { postTxid, txid })
}
@@ -1,4 +1,4 @@
import { utf8FromPush, logProcessError, normalizeTwoPushMemoDatas, postHeightKey } from './helpers.js'
import { utf8FromPush, logProcessError, normalizeTwoPushMemoDatas, postHeightKey, addrPostHeightKey } from './helpers.js'
import { MAX_POST_SIZE } from '../../lib/memo-codes.js'
// Create a record only when it does not already exist (idempotent writes).
@@ -31,8 +31,10 @@ export async function handlePost (ctx) {
const postData = { addr: signerAddr, text, seen, blockHeight }
const heightKey = postHeightKey(blockHeight, txid)
const addrHeightKey = addrPostHeightKey(signerAddr, blockHeight, txid)
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
@@ -0,0 +1,44 @@
import { assert } from 'chai'
import sinon from 'sinon'
import { handleLike } from '../../../../src/use-cases/action-types/like.js'
import { PREFIX_LIKE } from '../../../../src/lib/memo-codes.js'
describe('#handleLike', () => {
it('should save a like and its postLikes index entry', async () => {
const postTxid = Buffer.alloc(32, 0xab)
const likeCreate = sinon.stub().resolves({ success: true })
const postLikeCreate = sinon.stub().resolves({ success: true })
const processErrorDb = { create: sinon.stub() }
const adapters = {
likeDb: { create: likeCreate },
postLikeDb: { create: postLikeCreate },
processErrorDb
}
await handleLike({
adapters,
txid: 'like-abc',
signerAddr: 'bitcoincash:qptest',
seen: 1000,
blockHeight: 600150,
txDetails: { vout: [] },
decoded: {
action: 'like',
prefix: PREFIX_LIKE,
pushDatas: [PREFIX_LIKE, postTxid]
}
})
assert.equal(likeCreate.callCount, 1)
assert.equal(likeCreate.firstCall.args[0], 'like-abc')
assert.equal(likeCreate.firstCall.args[1].postTxid, postTxid.toString('hex').match(/.{2}/g).reverse().join(''))
assert.equal(postLikeCreate.callCount, 1)
const expectedPostTxid = postTxid.toString('hex').match(/.{2}/g).reverse().join('')
assert.equal(postLikeCreate.firstCall.args[0], `${expectedPostTxid}:like-abc`)
assert.equal(postLikeCreate.firstCall.args[1].postTxid, expectedPostTxid)
assert.equal(postLikeCreate.firstCall.args[1].txid, 'like-abc')
})
})
@@ -9,10 +9,13 @@ describe('#handlePost', () => {
const get = sinon.stub().rejects(new Error('not found'))
const postHeightCreate = sinon.stub().resolves({ success: true })
const postHeightGet = sinon.stub().rejects(new Error('not found'))
const addrPostHeightCreate = sinon.stub().resolves({ success: true })
const addrPostHeightGet = sinon.stub().rejects(new Error('not found'))
const adapters = {
postDb: { create, get },
postHeightDb: { create: postHeightCreate, get: postHeightGet },
addrPostHeightDb: { create: addrPostHeightCreate, get: addrPostHeightGet },
processErrorDb: { create: sinon.stub() }
}
@@ -39,6 +42,11 @@ describe('#handlePost', () => {
assert.equal(postHeightCreate.firstCall.args[0], '000000600100:abc123')
assert.equal(postHeightCreate.firstCall.args[1].txid, 'abc123')
assert.equal(postHeightCreate.firstCall.args[1].blockHeight, 600100)
assert.equal(addrPostHeightCreate.callCount, 1)
assert.equal(addrPostHeightCreate.firstCall.args[0], 'bitcoincash:qptest:000000600100:abc123')
assert.equal(addrPostHeightCreate.firstCall.args[1].txid, 'abc123')
assert.equal(addrPostHeightCreate.firstCall.args[1].blockHeight, 600100)
})
it('should not duplicate postHeight entries when reprocessing', async () => {
@@ -46,10 +54,13 @@ describe('#handlePost', () => {
const get = sinon.stub().resolves({ addr: 'bitcoincash:qptest', text: 'hello memo', seen: 1000, blockHeight: 600100 })
const postHeightCreate = sinon.stub().resolves({ success: true })
const postHeightGet = sinon.stub().resolves({ txid: 'abc123', blockHeight: 600100 })
const addrPostHeightCreate = sinon.stub().resolves({ success: true })
const addrPostHeightGet = sinon.stub().resolves({ txid: 'abc123', blockHeight: 600100 })
const adapters = {
postDb: { create, get },
postHeightDb: { create: postHeightCreate, get: postHeightGet },
addrPostHeightDb: { create: addrPostHeightCreate, get: addrPostHeightGet },
processErrorDb: { create: sinon.stub() }
}
@@ -69,6 +80,7 @@ describe('#handlePost', () => {
assert.equal(create.callCount, 0)
assert.equal(postHeightCreate.callCount, 0)
assert.equal(addrPostHeightCreate.callCount, 0)
})
it('should accept a post whose text is exactly at the maximum size', async () => {
@@ -76,11 +88,14 @@ describe('#handlePost', () => {
const get = sinon.stub().rejects(new Error('not found'))
const postHeightCreate = sinon.stub().resolves({ success: true })
const postHeightGet = sinon.stub().rejects(new Error('not found'))
const addrPostHeightCreate = sinon.stub().resolves({ success: true })
const addrPostHeightGet = sinon.stub().rejects(new Error('not found'))
const processErrorDb = { create: sinon.stub() }
const adapters = {
postDb: { create, get },
postHeightDb: { create: postHeightCreate, get: postHeightGet },
addrPostHeightDb: { create: addrPostHeightCreate, get: addrPostHeightGet },
processErrorDb
}
@@ -14,12 +14,15 @@ describe('#handleReply', () => {
const postDbCreate = sinon.stub().resolves({ success: true })
const postHeightGet = sinon.stub().rejects(new Error('not found'))
const postHeightCreate = sinon.stub().resolves({ success: true })
const addrPostHeightGet = sinon.stub().rejects(new Error('not found'))
const addrPostHeightCreate = sinon.stub().resolves({ success: true })
const adapters = {
postParentDb: { create: postParentCreate },
postChildDb: { create: postChildCreate },
postDb: { get: postDbGet, create: postDbCreate },
postHeightDb: { get: postHeightGet, create: postHeightCreate },
addrPostHeightDb: { get: addrPostHeightGet, create: addrPostHeightCreate },
processErrorDb: { create: sinon.stub() }
}
@@ -50,5 +53,9 @@ describe('#handleReply', () => {
assert.equal(postHeightCreate.callCount, 1)
assert.equal(postHeightCreate.firstCall.args[0], '000000600150:reply-abc')
assert.equal(postHeightCreate.firstCall.args[1].txid, 'reply-abc')
assert.equal(addrPostHeightCreate.callCount, 1)
assert.equal(addrPostHeightCreate.firstCall.args[0], 'bitcoincash:qptest:000000600150:reply-abc')
assert.equal(addrPostHeightCreate.firstCall.args[1].txid, 'reply-abc')
})
})