mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo.git
synced 2026-09-21 16:52:01 -07:00
Implement notifications query performance
Bound GET /posts/notifications/:addr to the viewer's activity in a configurable block window (NOTIFICATION_BLOCK_WINDOW, default 25000): read the viewer's posts from addrPostHeights, prefix-scan postLikes and postChildren for those posts only, and read follows from the new followeeHeights index. The global likes, postChildren, and follows stores are no longer full-scanned, and pagination.total counts only in-window notifications. Add the indexer write of a followeeHeights entry on every follow and unfollow, the DB followee-index backfill utility and CLI, the followeeheight /level route, the notification window config, unit, property, and acceptance coverage, and developer docs. By coder.
This commit is contained in:
@@ -2,3 +2,4 @@ PORT=5021
|
||||
SVC_ENV=development
|
||||
BACKUP_QTY=3
|
||||
EXIT_ON_MISSING_BACKUP=false
|
||||
NOTIFICATION_BLOCK_WINDOW=25000
|
||||
|
||||
@@ -24,11 +24,24 @@ import TopicFollowState from '../../src/use-cases/topic-follow-state.js'
|
||||
import ListTopicFollowers from '../../src/use-cases/list-topic-followers.js'
|
||||
import MuteState from '../../src/use-cases/mute-state.js'
|
||||
import ListMuted from '../../src/use-cases/list-muted.js'
|
||||
import ListNotifications from '../../src/use-cases/list-notifications.js'
|
||||
import GetPoll from '../../src/use-cases/get-poll.js'
|
||||
import GetPollOptions from '../../src/use-cases/get-poll-options.js'
|
||||
import GetPollVotes from '../../src/use-cases/get-poll-votes.js'
|
||||
import { repairTxidEncoding } from '../../src/lib/repair-txid-encoding.js'
|
||||
import { backfillTopicIndexes, topicRecencyKey } from '../../src/lib/backfill-topic-indexes.js'
|
||||
import { backfillFolloweeIndex, followeeHeightKey } from '../../src/lib/backfill-followee-index.js'
|
||||
import BCHJS from '@psf/bch-js'
|
||||
|
||||
const bchjs = new BCHJS({ restURL: process.env.RESTURL || 'https://api.fullstack.cash/v5/' })
|
||||
|
||||
function hash160 (addr) {
|
||||
return bchjs.Address.toHash160(addr)
|
||||
}
|
||||
|
||||
function padHeight (blockHeight) {
|
||||
return String(blockHeight ?? 0).padStart(12, '0')
|
||||
}
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const tmpDir = path.resolve(__dirname, '..', '..', 'tmp', 'acceptance')
|
||||
@@ -98,6 +111,8 @@ async function createWorld () {
|
||||
const postsGetCounter = { calls: 0 }
|
||||
const likesIteratorCounter = { calls: 0 }
|
||||
const postLikesIteratorCounter = { calls: 0 }
|
||||
const followsIteratorCounter = { calls: 0 }
|
||||
const followeeHeightsIteratorCounter = { calls: 0, entries: 0 }
|
||||
const roomsIteratorCounter = { calls: 0, entries: 0 }
|
||||
const topicRecencyIteratorCounter = { calls: 0, entries: 0 }
|
||||
wrapIterator(adapters.level.postHeightsDb, postHeightsIteratorCounter)
|
||||
@@ -106,6 +121,8 @@ async function createWorld () {
|
||||
wrapGet(adapters.level.postsDb, postsGetCounter)
|
||||
wrapIterator(adapters.level.likesDb, likesIteratorCounter)
|
||||
wrapIterator(adapters.level.postLikesDb, postLikesIteratorCounter)
|
||||
wrapIterator(adapters.level.followsDb, followsIteratorCounter)
|
||||
wrapIterator(adapters.level.followeeHeightsDb, followeeHeightsIteratorCounter)
|
||||
wrapIterator(adapters.level.roomsDb, roomsIteratorCounter)
|
||||
wrapIterator(adapters.level.topicRecencyDb, topicRecencyIteratorCounter)
|
||||
|
||||
@@ -121,6 +138,7 @@ async function createWorld () {
|
||||
const listTopicFollowers = new ListTopicFollowers({ adapters })
|
||||
const muteState = new MuteState({ adapters })
|
||||
const listMuted = new ListMuted({ adapters })
|
||||
const listNotifications = new ListNotifications({ adapters })
|
||||
const getPoll = new GetPoll({ adapters })
|
||||
const getPollOptions = new GetPollOptions({ adapters })
|
||||
const getPollVotes = new GetPollVotes({ adapters })
|
||||
@@ -141,6 +159,7 @@ async function createWorld () {
|
||||
listTopicFollowers,
|
||||
muteState,
|
||||
listMuted,
|
||||
listNotifications,
|
||||
getPoll,
|
||||
getPollOptions,
|
||||
getPollVotes,
|
||||
@@ -150,6 +169,8 @@ async function createWorld () {
|
||||
postsGetCounter,
|
||||
likesIteratorCounter,
|
||||
postLikesIteratorCounter,
|
||||
followsIteratorCounter,
|
||||
followeeHeightsIteratorCounter,
|
||||
roomsIteratorCounter,
|
||||
topicRecencyIteratorCounter,
|
||||
getLastResponse: () => lastResponse,
|
||||
@@ -237,6 +258,16 @@ async function loadFixture (world, name) {
|
||||
return
|
||||
}
|
||||
|
||||
if (name === 'follows-by-followee') {
|
||||
await loadFollowsByFollowee(world)
|
||||
return
|
||||
}
|
||||
|
||||
if (name === 'notifications-window') {
|
||||
await loadNotificationsWindow(world)
|
||||
return
|
||||
}
|
||||
|
||||
if (name === 'many-posts-with-replies') {
|
||||
await loadManyPostsWithReplies(world)
|
||||
return
|
||||
@@ -498,6 +529,169 @@ async function loadFollows (world) {
|
||||
}
|
||||
}
|
||||
|
||||
// Fixture "follows-by-followee" from backfill-followee-index.feature: raw
|
||||
// follows records the backfill projects into followeeHeights.
|
||||
async function loadFollowsByFollowee (world) {
|
||||
const viewer = 'bitcoincash:qqg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zye3kwllue'
|
||||
const followerA = 'bitcoincash:qq3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygrg4dtdzf'
|
||||
const followerB = 'bitcoincash:qqenxvenxvenxvenxvenxvenxvenxvenxvn254yg3p'
|
||||
const followerC = 'bitcoincash:qpzyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs7fn3s6pt'
|
||||
const other = 'bitcoincash:qp24242424242424242424242424242425wtjflljr'
|
||||
|
||||
const records = [
|
||||
{ follower: followerA, followee: viewer, unfollow: false, height: 690400 },
|
||||
{ follower: followerB, followee: viewer, unfollow: true, height: 690600 },
|
||||
// The Examples table lists followee qpzyg... and follower qp2424... for this
|
||||
// record, so store it that orientation to match the asserted index key.
|
||||
{ follower: other, followee: followerC, unfollow: false, height: 690200 }
|
||||
]
|
||||
|
||||
for (const record of records) {
|
||||
const followeePkHash = hash160(record.followee)
|
||||
await world.adapters.level.followsDb.put(`${record.follower}:${followeePkHash}`, {
|
||||
followerAddr: record.follower,
|
||||
followeePkHash,
|
||||
unfollow: record.unfollow,
|
||||
txid: `follow-${record.follower.slice(-8)}`,
|
||||
seen: 1,
|
||||
blockHeight: record.height
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Fixture "notifications-window" from notifications-query-performance.feature.
|
||||
async function loadNotificationsWindow (world) {
|
||||
const viewer = 'bitcoincash:qqg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zye3kwllue'
|
||||
const followerA = 'bitcoincash:qq3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygrg4dtdzf'
|
||||
const followerB = 'bitcoincash:qqenxvenxvenxvenxvenxvenxvenxvenxvn254yg3p'
|
||||
const oldActor = 'bitcoincash:qpzyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs7fn3s6pt'
|
||||
const liker = 'bitcoincash:qp24242424242424242424242424242425wtjflljr'
|
||||
const replier = 'bitcoincash:qpnxvenxvenxvenxvenxvenxvenxvenxvc5j32tdvn'
|
||||
|
||||
// The feature identifies the viewer by the fixture name rather than an
|
||||
// example column, so expose it for step resolution.
|
||||
world.fixtureViewer = viewer
|
||||
|
||||
await world.adapters.level.statusDb.put('status', {
|
||||
startBlockHeight: 0,
|
||||
syncedBlockHeight: 700000,
|
||||
chainBlockHeight: 700000
|
||||
})
|
||||
|
||||
const posts = [
|
||||
{ txid: 'post-recent', addr: viewer, blockHeight: 690000, text: 'recent' },
|
||||
{ txid: 'post-old', addr: viewer, blockHeight: 600000, text: 'old' },
|
||||
{ txid: 'post-other', addr: followerA, blockHeight: 690000, text: 'other' }
|
||||
]
|
||||
for (const post of posts) {
|
||||
await world.adapters.level.postsDb.put(post.txid, {
|
||||
addr: post.addr,
|
||||
text: post.text,
|
||||
seen: post.blockHeight,
|
||||
blockHeight: post.blockHeight
|
||||
})
|
||||
await world.adapters.level.postHeightsDb.put(`${padHeight(post.blockHeight)}:${post.txid}`, {
|
||||
txid: post.txid,
|
||||
blockHeight: post.blockHeight
|
||||
})
|
||||
await world.adapters.level.addrPostHeightsDb.put(
|
||||
`${post.addr}:${padHeight(post.blockHeight)}:${post.txid}`,
|
||||
{ txid: post.txid, addr: post.addr, blockHeight: post.blockHeight }
|
||||
)
|
||||
}
|
||||
|
||||
const likes = [
|
||||
{ txid: 'like-recent', postTxid: 'post-recent', addr: liker, blockHeight: 690100 },
|
||||
{ txid: 'like-old', postTxid: 'post-old', addr: oldActor, blockHeight: 690200 },
|
||||
{ txid: 'like-other', postTxid: 'post-other', addr: followerA, blockHeight: 690300 }
|
||||
]
|
||||
for (const like of likes) {
|
||||
await world.adapters.level.likesDb.put(like.txid, {
|
||||
addr: like.addr,
|
||||
postTxid: like.postTxid,
|
||||
seen: like.blockHeight,
|
||||
tip: 0,
|
||||
blockHeight: like.blockHeight
|
||||
})
|
||||
await world.adapters.level.postLikesDb.put(
|
||||
`${like.postTxid}:${like.txid}`,
|
||||
{ postTxid: like.postTxid, txid: like.txid }
|
||||
)
|
||||
}
|
||||
|
||||
const replies = [
|
||||
{ parent: 'post-recent', child: 'reply-recent', addr: replier, blockHeight: 690150 },
|
||||
{ parent: 'post-old', child: 'reply-old', addr: oldActor, blockHeight: 690250 },
|
||||
{ parent: 'post-other', child: 'reply-other', addr: followerA, blockHeight: 690300 }
|
||||
]
|
||||
for (const reply of replies) {
|
||||
await world.adapters.level.postsDb.put(reply.child, {
|
||||
addr: reply.addr,
|
||||
text: 'reply',
|
||||
seen: reply.blockHeight,
|
||||
blockHeight: reply.blockHeight
|
||||
})
|
||||
await world.adapters.level.postChildrenDb.put(
|
||||
`${reply.parent}:${reply.child}`,
|
||||
{ parentTxid: reply.parent, childTxid: reply.child, blockHeight: reply.blockHeight }
|
||||
)
|
||||
}
|
||||
|
||||
const viewerHash = hash160(viewer)
|
||||
const followEvents = [
|
||||
{ follower: followerA, height: 690400, unfollow: false },
|
||||
{ follower: followerB, height: 690550, unfollow: false },
|
||||
{ follower: followerB, height: 690600, unfollow: true },
|
||||
{ follower: oldActor, height: 600000, unfollow: false }
|
||||
]
|
||||
for (const event of followEvents) {
|
||||
const record = {
|
||||
followerAddr: event.follower,
|
||||
followeePkHash: viewerHash,
|
||||
unfollow: event.unfollow,
|
||||
txid: `follow-${event.follower.slice(-6)}-${event.height}`,
|
||||
seen: event.height,
|
||||
blockHeight: event.height
|
||||
}
|
||||
await world.adapters.level.followeeHeightsDb.put(
|
||||
followeeHeightKey(viewerHash, event.height, event.follower),
|
||||
record
|
||||
)
|
||||
// Mirror the event in the follows store; the query must ignore it.
|
||||
await world.adapters.level.followsDb.put(`${event.follower}:${viewerHash}`, record)
|
||||
}
|
||||
|
||||
// Filler so an accidental full-store scan is observable.
|
||||
for (let i = 0; i < 100; i++) {
|
||||
await world.adapters.level.likesDb.put(`filler-like-${i}`, {
|
||||
addr: 'bitcoincash:filler',
|
||||
postTxid: `filler-post-${i}`,
|
||||
seen: i,
|
||||
tip: 0,
|
||||
blockHeight: 1
|
||||
})
|
||||
await world.adapters.level.postLikesDb.put(
|
||||
`filler-post-${i}:filler-like-${i}`,
|
||||
{ postTxid: `filler-post-${i}`, txid: `filler-like-${i}` }
|
||||
)
|
||||
await world.adapters.level.postChildrenDb.put(
|
||||
`filler-parent-${i}:filler-child-${i}`,
|
||||
{ parentTxid: `filler-parent-${i}`, childTxid: `filler-child-${i}`, blockHeight: 1 }
|
||||
)
|
||||
await world.adapters.level.followeeHeightsDb.put(
|
||||
followeeHeightKey(`fillerhash-${i}`, 690000, `filler-follower-${i}`),
|
||||
{
|
||||
followerAddr: `filler-follower-${i}`,
|
||||
followeePkHash: `fillerhash-${i}`,
|
||||
unfollow: false,
|
||||
txid: `filler-${i}`,
|
||||
seen: i,
|
||||
blockHeight: 690000
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTopicsWithPosts (world) {
|
||||
const roomEntries = [
|
||||
{ key: 'bitcoin:post-300', room: 'bitcoin', txid: 'post-300', type: 'post', blockHeight: 300 },
|
||||
@@ -872,7 +1066,7 @@ const handlers = [
|
||||
},
|
||||
{
|
||||
name: 'bounded postChildren entries read',
|
||||
pattern: /^the postChildren store was read at most (<max_entries>) entries$/,
|
||||
pattern: /^the postChildren store was read at most (<[A-Za-z0-9_]+>) entries$/,
|
||||
run (m, example, world) {
|
||||
const max = parseInt(resolveParam(m[1], example), 10)
|
||||
const reads = world.postChildrenIteratorCounter.entries || 0
|
||||
@@ -1732,6 +1926,156 @@ const handlers = [
|
||||
throw new Error(`Expected muted ${expected.join(',')}, got ${actual.join(',')}`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'db instance with follows and followeeHeights stores',
|
||||
pattern: /^a psf-memo-db instance with follows and followeeHeights stores$/,
|
||||
async run () {
|
||||
// World is already created with both stores.
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'run followee index backfill utility',
|
||||
pattern: /^the followee index backfill utility is run$/,
|
||||
async run (m, example, world) {
|
||||
await backfillFolloweeIndex({
|
||||
followsDb: world.adapters.level.followsDb,
|
||||
followeeHeightsDb: world.adapters.level.followeeHeightsDb
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'run followee index backfill utility again',
|
||||
pattern: /^the followee index backfill utility is run again$/,
|
||||
async run (m, example, world) {
|
||||
await backfillFolloweeIndex({
|
||||
followsDb: world.adapters.level.followsDb,
|
||||
followeeHeightsDb: world.adapters.level.followeeHeightsDb
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'followeeHeights store contains entry',
|
||||
pattern: /^the followeeHeights store contains (<[A-Za-z0-9_]+>) entr(?:y|ies) for followee (<[A-Za-z0-9_]+>) from (<[A-Za-z0-9_]+>) at block height (<[A-Za-z0-9_]+>) marked unfollow (<[A-Za-z0-9_]+>|true|false)$/,
|
||||
async run (m, example, world) {
|
||||
const expectedCount = parseInt(resolveParam(m[1], example), 10)
|
||||
const followee = resolveParam(m[2], example)
|
||||
const follower = resolveParam(m[3], example)
|
||||
const height = parseInt(resolveParam(m[4], example), 10)
|
||||
const expectedUnfollow = resolveParam(m[5], example) === 'true'
|
||||
const key = followeeHeightKey(hash160(followee), height, follower)
|
||||
let count = 0
|
||||
for await (const [k, value] of world.adapters.level.followeeHeightsDb.iterator()) {
|
||||
if (k === key && value?.unfollow === expectedUnfollow) count++
|
||||
}
|
||||
if (count !== expectedCount) {
|
||||
throw new Error(`Expected ${expectedCount} followeeHeights entry/entries for ${key} marked unfollow ${expectedUnfollow}, got ${count}`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'db instance with notification stores',
|
||||
pattern: /^a psf-memo-db instance with posts, postHeights, addrPostHeights, postChildren, likes, postLikes, follows, followeeHeights, and status stores$/,
|
||||
async run () {
|
||||
// World is already created with all stores.
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'set notification window',
|
||||
pattern: /^a notification window of (\S+) blocks$/,
|
||||
run (m, example, world) {
|
||||
const window = parseInt(resolveParam(m[1], example), 10)
|
||||
world.adapters.notificationsQuery.notificationBlockWindow = window
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'request notifications',
|
||||
pattern: /^the client requests \/posts\/notifications\/(<[A-Za-z0-9_]+>) with limit (<[A-Za-z0-9_]+>) and offset (<[A-Za-z0-9_]+>)$/,
|
||||
async run (m, example, world) {
|
||||
const addr = m[1] === '<viewer>' ? world.fixtureViewer : resolveParam(m[1], example)
|
||||
const limit = parseInt(resolveParam(m[2], example), 10)
|
||||
const offset = parseInt(resolveParam(m[3], example), 10)
|
||||
const resp = await world.listNotifications.execute({ addr, limit, offset })
|
||||
world.setLastResponse(resp)
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'response contains N notifications',
|
||||
pattern: /^the response contains (<[A-Za-z0-9_]+>) notifications$/,
|
||||
run (m, example, world) {
|
||||
const expected = parseInt(resolveParam(m[1], example), 10)
|
||||
const notifications = world.getLastResponse().notifications
|
||||
if (notifications.length !== expected) {
|
||||
throw new Error(`Expected ${expected} notifications, got ${notifications.length}`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'notification at index',
|
||||
pattern: /^the response notification at index (<[A-Za-z0-9_]+>) has type (<[A-Za-z0-9_]+>) from (<[A-Za-z0-9_]+>)$/,
|
||||
run (m, example, world) {
|
||||
const index = parseInt(resolveParam(m[1], example), 10)
|
||||
const type = resolveParam(m[2], example)
|
||||
const actor = resolveParam(m[3], example)
|
||||
const notification = world.getLastResponse().notifications[index]
|
||||
if (!notification) {
|
||||
throw new Error(`No notification at index ${index}`)
|
||||
}
|
||||
if (notification.type !== type || notification.addr !== actor) {
|
||||
throw new Error(`Expected ${type} from ${actor} at index ${index}, got ${notification.type} from ${notification.addr}`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'contains no notification from actor',
|
||||
pattern: /^the response contains no notification from (<[A-Za-z0-9_]+>)$/,
|
||||
run (m, example, world) {
|
||||
const actor = resolveParam(m[1], example)
|
||||
const found = world.getLastResponse().notifications.find((n) => n.addr === actor)
|
||||
if (found) {
|
||||
throw new Error(`Expected no notification from ${actor}, got ${JSON.stringify(found)}`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'bounded postLikes entries read',
|
||||
pattern: /^the postLikes store was read at most (<[A-Za-z0-9_]+>) entries$/,
|
||||
run (m, example, world) {
|
||||
const max = parseInt(resolveParam(m[1], example), 10)
|
||||
const reads = world.postLikesIteratorCounter.entries || 0
|
||||
if (reads > max) {
|
||||
throw new Error(`Read ${reads} postLikes entries, expected at most ${max}`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'bounded followeeHeights entries read',
|
||||
pattern: /^the followeeHeights store was read at most (<[A-Za-z0-9_]+>) entries$/,
|
||||
run (m, example, world) {
|
||||
const max = parseInt(resolveParam(m[1], example), 10)
|
||||
const reads = world.followeeHeightsIteratorCounter.entries || 0
|
||||
if (reads > max) {
|
||||
throw new Error(`Read ${reads} followeeHeights entries, expected at most ${max}`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'likes store was not iterated',
|
||||
pattern: /^the likes store was not iterated$/,
|
||||
run (m, example, world) {
|
||||
if (world.likesIteratorCounter.calls !== 0) {
|
||||
throw new Error(`Expected likes store not to be iterated, got ${world.likesIteratorCounter.calls} call(s)`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'follows store was not iterated',
|
||||
pattern: /^the follows store was not iterated$/,
|
||||
run (m, example, world) {
|
||||
if (world.followsIteratorCounter.calls !== 0) {
|
||||
throw new Error(`Expected follows store not to be iterated, got ${world.followsIteratorCounter.calls} call(s)`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
]
|
||||
|
||||
Vendored
+6
-1
@@ -12,5 +12,10 @@ export default {
|
||||
useIpfs: false,
|
||||
version: pkgInfo.version,
|
||||
backupQty: process.env.BACKUP_QTY ? parseInt(process.env.BACKUP_QTY) : 3,
|
||||
exitOnMissingBackup: process.env.EXIT_ON_MISSING_BACKUP === 'true'
|
||||
exitOnMissingBackup: process.env.EXIT_ON_MISSING_BACKUP === 'true',
|
||||
// Number of blocks before the chain tip that still count as a notification.
|
||||
// GET /posts/notifications/:addr uses cutoff = chainBlockHeight - window.
|
||||
notificationBlockWindow: process.env.NOTIFICATION_BLOCK_WINDOW
|
||||
? parseInt(process.env.NOTIFICATION_BLOCK_WINDOW, 10)
|
||||
: 25000
|
||||
}
|
||||
|
||||
@@ -14,7 +14,8 @@ import SearchQuery from './search-query.js'
|
||||
import NotificationsQuery from './notifications-query.js'
|
||||
|
||||
class Adapters {
|
||||
constructor () {
|
||||
constructor (localConfig = {}) {
|
||||
this.notificationBlockWindow = localConfig.notificationBlockWindow
|
||||
this.levelDb = new LevelDb()
|
||||
this.openDatabases = this.openDatabases.bind(this)
|
||||
this.start = this.start.bind(this)
|
||||
@@ -68,12 +69,14 @@ class Adapters {
|
||||
})
|
||||
this.notificationsQuery = new NotificationsQuery({
|
||||
postsDb: level.postsDb,
|
||||
postParentsDb: level.postParentsDb,
|
||||
addrPostHeightsDb: level.addrPostHeightsDb,
|
||||
postChildrenDb: level.postChildrenDb,
|
||||
likesDb: level.likesDb,
|
||||
postLikesDb: level.postLikesDb,
|
||||
followsDb: level.followsDb,
|
||||
muteQuery: this.muteQuery
|
||||
likesDb: level.likesDb,
|
||||
followeeHeightsDb: level.followeeHeightsDb,
|
||||
statusDb: level.statusDb,
|
||||
muteQuery: this.muteQuery,
|
||||
notificationBlockWindow: this.notificationBlockWindow
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ const DB_NAMES = [
|
||||
'profiles',
|
||||
'profilePics',
|
||||
'follows',
|
||||
'followeeHeights',
|
||||
'mutes',
|
||||
'rooms',
|
||||
'topicSummaries',
|
||||
|
||||
@@ -1,74 +1,150 @@
|
||||
/*
|
||||
Adapter for aggregating the viewer's notifications.
|
||||
|
||||
Notifications are read-only: the DB collects replies to the viewer's posts,
|
||||
likes on the viewer's posts, and new follows of the viewer, then returns them
|
||||
sorted newest-first with limit/offset pagination.
|
||||
Notifications are read-only: the DB collects likes on the viewer's posts,
|
||||
replies to the viewer's posts, and new follows of the viewer, then returns
|
||||
them sorted newest-first with limit/offset pagination.
|
||||
|
||||
The work is bounded to the viewer's activity inside a configurable block
|
||||
window (notificationBlockWindow, default 25000; cutoff =
|
||||
status.chainBlockHeight - window):
|
||||
|
||||
- the viewer's own posts come from the addrPostHeights index, range-limited
|
||||
to the window;
|
||||
- likes and replies come from prefix-scanning postLikes and postChildren
|
||||
for those posts only; the global likes store is never iterated and
|
||||
postChildren is only scanned per viewer post;
|
||||
- follows come from the followeeHeights index, range-limited to the window;
|
||||
the follows store is never iterated.
|
||||
|
||||
A notification is drawn from the viewer's content inside the window, so an
|
||||
interaction with a post older than the window is not returned even when the
|
||||
interaction itself is recent.
|
||||
*/
|
||||
|
||||
import BCHJS from '@psf/bch-js'
|
||||
import { getPostOrNull } from './lib/get-post-or-null.js'
|
||||
import { loadMutedAddrs } from './lib/muted-posts.js'
|
||||
|
||||
const HEIGHT_PAD = 12
|
||||
const DEFAULT_NOTIFICATION_BLOCK_WINDOW = 25000
|
||||
|
||||
function padHeight (blockHeight) {
|
||||
return String(blockHeight ?? 0).padStart(HEIGHT_PAD, '0')
|
||||
}
|
||||
|
||||
// Read a record by key, returning null when it does not exist.
|
||||
async function getRecordOrNull (db, key) {
|
||||
try {
|
||||
return await db.get(key)
|
||||
} catch (err) {
|
||||
if (err.notFound || err.code === 'LEVEL_NOT_FOUND' || err.response?.status === 404) return null
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
class NotificationsQuery {
|
||||
constructor (localConfig = {}) {
|
||||
const {
|
||||
postsDb,
|
||||
postParentsDb,
|
||||
addrPostHeightsDb,
|
||||
postChildrenDb,
|
||||
likesDb,
|
||||
postLikesDb,
|
||||
followsDb,
|
||||
likesDb,
|
||||
followeeHeightsDb,
|
||||
statusDb = null,
|
||||
muteQuery,
|
||||
notificationBlockWindow,
|
||||
bchjs = new BCHJS({ restURL: process.env.RESTURL || 'https://api.fullstack.cash/v5/' })
|
||||
} = localConfig
|
||||
|
||||
if (!postsDb) {
|
||||
throw new Error('postsDb required when instantiating NotificationsQuery adapter.')
|
||||
}
|
||||
if (!postParentsDb) {
|
||||
throw new Error('postParentsDb required when instantiating NotificationsQuery adapter.')
|
||||
if (!addrPostHeightsDb) {
|
||||
throw new Error('addrPostHeightsDb required when instantiating NotificationsQuery adapter.')
|
||||
}
|
||||
if (!postChildrenDb) {
|
||||
throw new Error('postChildrenDb required when instantiating NotificationsQuery adapter.')
|
||||
}
|
||||
if (!likesDb) {
|
||||
throw new Error('likesDb required when instantiating NotificationsQuery adapter.')
|
||||
}
|
||||
if (!postLikesDb) {
|
||||
throw new Error('postLikesDb required when instantiating NotificationsQuery adapter.')
|
||||
}
|
||||
if (!followsDb) {
|
||||
throw new Error('followsDb required when instantiating NotificationsQuery adapter.')
|
||||
if (!likesDb) {
|
||||
throw new Error('likesDb required when instantiating NotificationsQuery adapter.')
|
||||
}
|
||||
if (!followeeHeightsDb) {
|
||||
throw new Error('followeeHeightsDb required when instantiating NotificationsQuery adapter.')
|
||||
}
|
||||
|
||||
this.postsDb = postsDb
|
||||
this.postParentsDb = postParentsDb
|
||||
this.addrPostHeightsDb = addrPostHeightsDb
|
||||
this.postChildrenDb = postChildrenDb
|
||||
this.likesDb = likesDb
|
||||
this.postLikesDb = postLikesDb
|
||||
this.followsDb = followsDb
|
||||
this.likesDb = likesDb
|
||||
this.followeeHeightsDb = followeeHeightsDb
|
||||
this.statusDb = statusDb
|
||||
this.muteQuery = muteQuery || null
|
||||
this.notificationBlockWindow = notificationBlockWindow ?? DEFAULT_NOTIFICATION_BLOCK_WINDOW
|
||||
this.bchjs = bchjs
|
||||
|
||||
this.listNotifications = this.listNotifications.bind(this)
|
||||
this._windowCutoff = this._windowCutoff.bind(this)
|
||||
this._scanViewerPosts = this._scanViewerPosts.bind(this)
|
||||
this._collectFollowNotifications = this._collectFollowNotifications.bind(this)
|
||||
this._collectLikeNotifications = this._collectLikeNotifications.bind(this)
|
||||
this._collectReplyNotifications = this._collectReplyNotifications.bind(this)
|
||||
this._replyNotificationChild = this._replyNotificationChild.bind(this)
|
||||
this._followNotificationAddr = this._followNotificationAddr.bind(this)
|
||||
this._likeNotificationPost = this._likeNotificationPost.bind(this)
|
||||
this._followerFromKey = this._followerFromKey.bind(this)
|
||||
this._sortNotifications = this._sortNotifications.bind(this)
|
||||
}
|
||||
|
||||
// Collect active follows where this address is the followee.
|
||||
async _collectFollowNotifications (addr, mutedAddrs) {
|
||||
const myHash160 = this.bchjs.Address.toHash160(addr)
|
||||
const notifications = []
|
||||
// The lowest block height that counts as inside the notification window, or
|
||||
// null when no status is available (treat the whole history as in-window).
|
||||
async _windowCutoff () {
|
||||
if (!this.statusDb) return null
|
||||
const status = await getRecordOrNull(this.statusDb, 'status')
|
||||
if (!status) return null
|
||||
const chainBlockHeight = status.chainBlockHeight ?? 0
|
||||
return chainBlockHeight - this.notificationBlockWindow
|
||||
}
|
||||
|
||||
for await (const [key, record] of this.followsDb.iterator()) {
|
||||
const followerAddr = this._followNotificationAddr(key, record, addr, myHash160, mutedAddrs)
|
||||
if (followerAddr === null) continue
|
||||
// Prefix-scan the viewer's posts within the window from addrPostHeights,
|
||||
// newest-first is not required because likes/replies are scanned per txid.
|
||||
async _scanViewerPosts (addr, cutoff) {
|
||||
const prefix = `${addr}:`
|
||||
const start = cutoff === null ? prefix : `${addr}:${padHeight(cutoff)}`
|
||||
const end = `${addr}:\uffff`
|
||||
const posts = []
|
||||
|
||||
for await (const [key, value] of this.addrPostHeightsDb.iterator({ gte: start, lte: end })) {
|
||||
const txid = value?.txid || key.slice(key.lastIndexOf(':') + 1)
|
||||
if (!txid) continue
|
||||
posts.push({ txid, blockHeight: value?.blockHeight ?? 0 })
|
||||
}
|
||||
|
||||
return posts
|
||||
}
|
||||
|
||||
// Collect the viewer's follows from the followeeHeights index within the
|
||||
// window, keeping the newest entry per follower and ignoring unfollows.
|
||||
async _collectFollowNotifications (addr, cutoff, mutedAddrs) {
|
||||
const myHash160 = this.bchjs.Address.toHash160(addr)
|
||||
const start = cutoff === null ? `${myHash160}:` : `${myHash160}:${padHeight(cutoff)}`
|
||||
const end = `${myHash160}:\uffff`
|
||||
const newestByFollower = new Map()
|
||||
|
||||
for await (const [key, record] of this.followeeHeightsDb.iterator({ gte: start, lte: end })) {
|
||||
const followerAddr = record?.followerAddr || this._followerFromKey(key)
|
||||
if (!followerAddr) continue
|
||||
// Ascending height order means the last entry for a follower wins.
|
||||
newestByFollower.set(followerAddr, record)
|
||||
}
|
||||
|
||||
const notifications = []
|
||||
for (const [followerAddr, record] of newestByFollower) {
|
||||
if (record.unfollow === true) continue
|
||||
if (followerAddr === addr) continue
|
||||
if (mutedAddrs.has(followerAddr)) continue
|
||||
|
||||
notifications.push({
|
||||
type: 'follow',
|
||||
@@ -82,89 +158,75 @@ class NotificationsQuery {
|
||||
return notifications
|
||||
}
|
||||
|
||||
// Return the follower address when a follow record is a valid follow
|
||||
// notification for addr, else null. Filters out unfollow records, follows of
|
||||
// other addresses, self-follows, and follows from muted addresses.
|
||||
_followNotificationAddr (key, record, addr, myHash160, mutedAddrs) {
|
||||
if (record.unfollow === true) return null
|
||||
if (record.followeePkHash !== myHash160) return null
|
||||
const followerAddr = record.followerAddr || key.slice(0, key.lastIndexOf(':'))
|
||||
if (followerAddr === addr) return null
|
||||
if (mutedAddrs.has(followerAddr)) return null
|
||||
return followerAddr
|
||||
}
|
||||
|
||||
// Collect likes on posts authored by this address, excluding self-likes.
|
||||
async _collectLikeNotifications (addr, mutedAddrs) {
|
||||
// Prefix-scan postLikes for each viewer post and load the like record to get
|
||||
// the actor and height. The global likes store is never iterated.
|
||||
async _collectLikeNotifications (posts, addr, mutedAddrs) {
|
||||
const notifications = []
|
||||
|
||||
for await (const [likeTxid, like] of this.likesDb.iterator()) {
|
||||
const post = await this._likeNotificationPost(like, addr, mutedAddrs)
|
||||
if (!post) continue
|
||||
for (const post of posts) {
|
||||
const prefix = `${post.txid}:`
|
||||
for await (const [key, value] of this.postLikesDb.iterator({ gte: prefix, lte: `${post.txid}:\uffff` })) {
|
||||
const likeTxid = value?.txid || key.slice(key.lastIndexOf(':') + 1)
|
||||
if (!likeTxid) continue
|
||||
|
||||
notifications.push({
|
||||
type: 'like',
|
||||
txid: likeTxid,
|
||||
addr: like.addr,
|
||||
postTxid: like.postTxid,
|
||||
blockHeight: like.blockHeight ?? 0,
|
||||
seen: like.seen ?? 0
|
||||
})
|
||||
const like = await getRecordOrNull(this.likesDb, likeTxid)
|
||||
if (!like) continue
|
||||
if (like.addr === addr) continue
|
||||
if (mutedAddrs.has(like.addr)) continue
|
||||
|
||||
notifications.push({
|
||||
type: 'like',
|
||||
txid: likeTxid,
|
||||
addr: like.addr,
|
||||
postTxid: post.txid,
|
||||
blockHeight: like.blockHeight ?? post.blockHeight ?? 0,
|
||||
seen: like.seen ?? 0
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return notifications
|
||||
}
|
||||
|
||||
// Return the liked post when a like is a valid like notification for addr,
|
||||
// else null. Filters out missing likes, self-likes, likes from muted
|
||||
// addresses, and likes on posts not authored by addr.
|
||||
async _likeNotificationPost (like, addr, mutedAddrs) {
|
||||
if (!like || like.addr === addr || mutedAddrs.has(like.addr)) return null
|
||||
const post = await getPostOrNull(this.postsDb, like.postTxid)
|
||||
if (!post || post.addr !== addr) return null
|
||||
return post
|
||||
}
|
||||
|
||||
// Collect replies to posts authored by this address, excluding own replies.
|
||||
async _collectReplyNotifications (addr, mutedAddrs) {
|
||||
// Prefix-scan postChildren for each viewer post and load the child post for
|
||||
// the actor and text. postChildren is only read per viewer post, never
|
||||
// full-scanned.
|
||||
async _collectReplyNotifications (posts, addr, mutedAddrs) {
|
||||
const notifications = []
|
||||
|
||||
for await (const [, child] of this.postChildrenDb.iterator()) {
|
||||
const parentTxid = child?.parentTxid
|
||||
const childTxid = child?.childTxid
|
||||
if (!parentTxid || !childTxid) continue
|
||||
for (const post of posts) {
|
||||
const prefix = `${post.txid}:`
|
||||
for await (const [key, child] of this.postChildrenDb.iterator({ gte: prefix, lte: `${post.txid}:\uffff` })) {
|
||||
const childTxid = child?.childTxid || key.slice(key.lastIndexOf(':') + 1)
|
||||
if (!childTxid) continue
|
||||
if (child?.parentTxid && child.parentTxid !== post.txid) continue
|
||||
|
||||
const childPost = await this._replyNotificationChild(child, addr)
|
||||
if (!childPost) continue
|
||||
if (mutedAddrs.has(childPost.addr)) continue
|
||||
const childPost = await getPostOrNull(this.postsDb, childTxid)
|
||||
if (!childPost) continue
|
||||
if (childPost.addr === addr) continue
|
||||
if (mutedAddrs.has(childPost.addr)) continue
|
||||
|
||||
notifications.push({
|
||||
type: 'reply',
|
||||
txid: childTxid,
|
||||
addr: childPost.addr,
|
||||
postTxid: parentTxid,
|
||||
text: childPost.text,
|
||||
blockHeight: child.blockHeight ?? childPost.blockHeight ?? 0,
|
||||
seen: childPost.seen ?? 0
|
||||
})
|
||||
notifications.push({
|
||||
type: 'reply',
|
||||
txid: childTxid,
|
||||
addr: childPost.addr,
|
||||
postTxid: post.txid,
|
||||
text: childPost.text,
|
||||
blockHeight: child?.blockHeight ?? childPost.blockHeight ?? post.blockHeight ?? 0,
|
||||
seen: childPost.seen ?? 0
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return notifications
|
||||
}
|
||||
|
||||
// Return the child post when a child record is a valid reply notification
|
||||
// for addr: it links a parent authored by addr to a child authored by someone
|
||||
// else. Returns null when the parent is missing or not authored by addr, or
|
||||
// the child is missing or authored by addr. Callers must have already
|
||||
// verified the record carries parentTxid and childTxid.
|
||||
async _replyNotificationChild (child, addr) {
|
||||
const parent = await getPostOrNull(this.postsDb, child.parentTxid)
|
||||
if (!parent || parent.addr !== addr) return null
|
||||
|
||||
const childPost = await getPostOrNull(this.postsDb, child.childTxid)
|
||||
if (!childPost || childPost.addr === addr) return null
|
||||
|
||||
return childPost
|
||||
// Recover the follower address from a followeeHeights key of the form
|
||||
// `${followeePkHash}:${paddedHeight}:${followerAddr}`. Cash addresses contain
|
||||
// a colon, so the follower is everything after the second colon.
|
||||
_followerFromKey (key) {
|
||||
const parts = String(key).split(':')
|
||||
return parts.slice(2).join(':')
|
||||
}
|
||||
|
||||
_sortNotifications (notifications) {
|
||||
@@ -178,9 +240,11 @@ class NotificationsQuery {
|
||||
async listNotifications (addr, { limit, offset } = {}) {
|
||||
const mutedAddrs = await loadMutedAddrs(this.muteQuery, addr)
|
||||
|
||||
const follows = await this._collectFollowNotifications(addr, mutedAddrs)
|
||||
const likes = await this._collectLikeNotifications(addr, mutedAddrs)
|
||||
const replies = await this._collectReplyNotifications(addr, mutedAddrs)
|
||||
const cutoff = await this._windowCutoff()
|
||||
const viewerPosts = await this._scanViewerPosts(addr, cutoff)
|
||||
const follows = await this._collectFollowNotifications(addr, cutoff, mutedAddrs)
|
||||
const likes = await this._collectLikeNotifications(viewerPosts, addr, mutedAddrs)
|
||||
const replies = await this._collectReplyNotifications(viewerPosts, addr, mutedAddrs)
|
||||
|
||||
const all = this._sortNotifications(follows.concat(likes).concat(replies))
|
||||
const total = all.length
|
||||
@@ -191,7 +255,3 @@ class NotificationsQuery {
|
||||
}
|
||||
|
||||
export default NotificationsQuery
|
||||
|
||||
// mutate4javascript-manifest-begin
|
||||
// {"version":1,"tested_at":"2026-09-04T20:09:09.555Z","module_hash":"5ba9ad53d1edc8797d7922446cb19e75a4158f8822eea588d9e94bb1fb2fa971","functions":[{"id":"func/NotificationsQuery.constructor","name":"NotificationsQuery.constructor","line":14,"end_line":62,"hash":"87579ac7d3e764ab30e21e73d9780aaa4c6d562c610b5e13b4ede182b694845e"},{"id":"func/NotificationsQuery._collectFollowNotifications","name":"NotificationsQuery._collectFollowNotifications","line":65,"end_line":83,"hash":"a4b9f141ee0de921232efff0aaf73503c0c4313fedac4c37b79894cd74f29877"},{"id":"func/NotificationsQuery._followNotificationAddr","name":"NotificationsQuery._followNotificationAddr","line":88,"end_line":95,"hash":"814bf4e51edac65108806f012ed3f0c12dc5e9dbe09dfb83af95e8292aab1669"},{"id":"func/NotificationsQuery._collectLikeNotifications","name":"NotificationsQuery._collectLikeNotifications","line":98,"end_line":116,"hash":"e3ab4911550211f3bd9f8f85139d69392433bb95d824b0fd8ac628eb9fe755cf"},{"id":"func/NotificationsQuery._likeNotificationPost","name":"NotificationsQuery._likeNotificationPost","line":121,"end_line":126,"hash":"c72690ed0975ca35ec00f1fb22a602c7301a327e8d36b17032e8e9a36a89adcb"},{"id":"func/NotificationsQuery._collectReplyNotifications","name":"NotificationsQuery._collectReplyNotifications","line":129,"end_line":153,"hash":"270ba026b18025bcef2c92776bbf8143f5f56da0c565285708d5196cf777ba83"},{"id":"func/NotificationsQuery._replyNotificationChild","name":"NotificationsQuery._replyNotificationChild","line":160,"end_line":168,"hash":"7ed6bf9d9f5147ba189c99304b76a1a0d77bc9c7909e194f9b74545c84227ed0"},{"id":"func/NotificationsQuery._sortNotifications","name":"NotificationsQuery._sortNotifications","line":170,"end_line":175,"hash":"61b4ae0b6d450e172db3e59240a3c2ccd5fdf0e6ea1e31023df7bb3d52fdbd5c"},{"id":"func/NotificationsQuery.listNotifications","name":"NotificationsQuery.listNotifications","line":178,"end_line":190,"hash":"3507c459f6dc22482aa693a92fe13da16af05d65dd4e37366a9b8db98654ec14"}]}
|
||||
// mutate4javascript-manifest-end
|
||||
|
||||
@@ -5,10 +5,11 @@
|
||||
import Adapters from '../adapters/index.js'
|
||||
import UseCases from '../use-cases/index.js'
|
||||
import RESTControllers from './rest-api/index.js'
|
||||
import config from '../../config/index.js'
|
||||
|
||||
class Controllers {
|
||||
constructor () {
|
||||
this.adapters = new Adapters()
|
||||
this.adapters = new Adapters({ notificationBlockWindow: config.notificationBlockWindow })
|
||||
this.useCases = new UseCases({ adapters: this.adapters })
|
||||
this.initAdapters = this.initAdapters.bind(this)
|
||||
this.initUseCases = this.initUseCases.bind(this)
|
||||
|
||||
@@ -46,6 +46,7 @@ export const ENTITY_CONFIG = [
|
||||
{ route: 'profile', dbProp: 'profilesDb', keyParam: 'addr', bodyIdField: 'addr', bodyDataField: 'profileData' },
|
||||
{ route: 'profilepic', dbProp: 'profilePicsDb', keyParam: 'addr', bodyIdField: 'addr', bodyDataField: 'profilePicData' },
|
||||
{ route: 'follow', dbProp: 'followsDb', keyParam: 'key', bodyIdField: 'key', bodyDataField: 'followData' },
|
||||
{ route: 'followeeheight', dbProp: 'followeeHeightsDb', keyParam: 'key', bodyIdField: 'key', bodyDataField: 'followeeHeightData' },
|
||||
{ route: 'room', dbProp: 'roomsDb', keyParam: 'key', bodyIdField: 'key', bodyDataField: 'roomData' },
|
||||
{ route: 'topicsummary', dbProp: 'topicSummariesDb', keyParam: 'key', bodyIdField: 'key', bodyDataField: 'topicSummaryData' },
|
||||
{ route: 'topicrecency', dbProp: 'topicRecencyDb', keyParam: 'key', bodyIdField: 'key', bodyDataField: 'topicRecencyData' },
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
Library to build the followeeHeights notification read index from an
|
||||
existing follows store.
|
||||
|
||||
followeeHeights is keyed `${followeePkHash}:${padded blockHeight}:${followerAddr}`
|
||||
with value `{ followerAddr, followeePkHash, unfollow, txid, seen, blockHeight }`.
|
||||
The read side range-scans one followee's entries to find its newest follow per
|
||||
follower, so the index must carry every follow and unfollow event at its block
|
||||
height.
|
||||
|
||||
The backfill writes one entry per follows record at that record's latest block
|
||||
height. It rebuilds from the follows store only, is idempotent, and leaves the
|
||||
follows store unchanged.
|
||||
|
||||
The LevelDB handles are injected so the logic stays testable and free of
|
||||
file-system concerns; the CLI wrapper in util/follow opens the real stores.
|
||||
*/
|
||||
|
||||
const HEIGHT_PAD = 12
|
||||
|
||||
export function followeeHeightKey (followeePkHash, blockHeight, followerAddr) {
|
||||
const padded = String(blockHeight ?? 0).padStart(HEIGHT_PAD, '0')
|
||||
return `${followeePkHash}:${padded}:${followerAddr}`
|
||||
}
|
||||
|
||||
// Recover the follower and followee from a follows key of the form
|
||||
// `${followerAddr}:${followeePkHash}`. Cash addresses contain a colon, so the
|
||||
// followee hash is the segment after the final colon.
|
||||
function partsFromKey (key) {
|
||||
const str = String(key)
|
||||
const idx = str.lastIndexOf(':')
|
||||
return {
|
||||
followerAddr: str.slice(0, idx),
|
||||
followeePkHash: str.slice(idx + 1)
|
||||
}
|
||||
}
|
||||
|
||||
export async function backfillFolloweeIndex ({ followsDb, followeeHeightsDb }) {
|
||||
let follows = 0
|
||||
|
||||
for await (const [key, record] of followsDb.iterator()) {
|
||||
const fallback = partsFromKey(key)
|
||||
const followerAddr = record?.followerAddr || fallback.followerAddr
|
||||
const followeePkHash = record?.followeePkHash || fallback.followeePkHash
|
||||
if (!followerAddr || !followeePkHash) continue
|
||||
|
||||
const blockHeight = record?.blockHeight ?? 0
|
||||
await followeeHeightsDb.put(followeeHeightKey(followeePkHash, blockHeight, followerAddr), {
|
||||
followerAddr,
|
||||
followeePkHash,
|
||||
unfollow: record?.unfollow ?? false,
|
||||
txid: record?.txid,
|
||||
seen: record?.seen,
|
||||
blockHeight
|
||||
})
|
||||
follows++
|
||||
}
|
||||
|
||||
return { follows }
|
||||
}
|
||||
@@ -3,180 +3,196 @@
|
||||
|
||||
The unit suite probes listNotifications at a handful of fixed fixtures.
|
||||
These properties pin down invariants that should hold over broad random
|
||||
notification records:
|
||||
notification records when every interaction is inside the window:
|
||||
|
||||
- membership: every returned notification originated from an active follow,
|
||||
a like on one of the viewer's posts, or a reply to one of the viewer's
|
||||
posts;
|
||||
- newest-first ordering: notifications are returned sorted by blockHeight
|
||||
descending.
|
||||
- tie-break ordering: when blockHeights tie, notifications are ordered by
|
||||
seen descending.
|
||||
descending, with seen descending as the tie-break;
|
||||
- pagination conservation: applying offset/limit returns exactly the full
|
||||
matching set sliced to the page, and reports an exact total.
|
||||
- membership: every returned notification originated from an active follow,
|
||||
a like on one of my posts, or a reply to one of my posts.
|
||||
|
||||
The reference implementation below mirrors the adapter's aggregation and
|
||||
sort logic so the two can be cross-checked.
|
||||
The reference implementation below mirrors the adapter's window-free
|
||||
aggregation and sort logic so the two can be cross-checked.
|
||||
*/
|
||||
|
||||
import test from 'node:test'
|
||||
import { seededRandom, forAll, intGen } from './harness.js'
|
||||
import NotificationsQuery from '../../src/adapters/notifications-query.js'
|
||||
|
||||
const rng = seededRandom(20260903)
|
||||
const rng = seededRandom(20260918)
|
||||
|
||||
const VIEWER = 'bitcoincash:viewer'
|
||||
const MY_HASH = 'hash-viewer'
|
||||
const OTHERS = ['bitcoincash:a', 'bitcoincash:b', 'bitcoincash:c']
|
||||
const VIEWER_POSTS = ['vp1', 'vp2', 'vp3', 'vp4']
|
||||
|
||||
function makeIterator (items) {
|
||||
return (async function * () {
|
||||
for (const item of items) yield item
|
||||
}())
|
||||
}
|
||||
const PAD = 12
|
||||
const pad = (h) => String(h).padStart(PAD, '0')
|
||||
|
||||
function buildQuery ({ posts, follows, likes, children }) {
|
||||
const postsDb = {
|
||||
async get (txid) {
|
||||
const post = posts.get(txid)
|
||||
if (!post) {
|
||||
function makeDb (entries = []) {
|
||||
const map = new Map(entries)
|
||||
return {
|
||||
map,
|
||||
async get (key) {
|
||||
if (!map.has(key)) {
|
||||
const err = new Error('not found')
|
||||
err.notFound = true
|
||||
throw err
|
||||
}
|
||||
return post
|
||||
}
|
||||
}
|
||||
const bchjs = {
|
||||
Address: {
|
||||
toHash160: (addr) => (addr === VIEWER ? MY_HASH : 'hash-' + addr)
|
||||
return map.get(key)
|
||||
},
|
||||
iterator (opts = {}) {
|
||||
let keys = [...map.keys()].sort()
|
||||
if (opts.gte !== undefined) keys = keys.filter((key) => key >= opts.gte)
|
||||
if (opts.lte !== undefined) keys = keys.filter((key) => key <= opts.lte)
|
||||
return (async function * () {
|
||||
for (const key of keys) yield [key, map.get(key)]
|
||||
}())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildQuery () {
|
||||
// statusDb null => no window; every generated interaction is in scope.
|
||||
return new NotificationsQuery({
|
||||
postsDb,
|
||||
postParentsDb: {},
|
||||
postChildrenDb: { iterator: () => makeIterator(children) },
|
||||
likesDb: { iterator: () => makeIterator(likes) },
|
||||
postLikesDb: {},
|
||||
followsDb: { iterator: () => makeIterator(follows) },
|
||||
bchjs
|
||||
postsDb: makeDb(),
|
||||
addrPostHeightsDb: makeDb(),
|
||||
postChildrenDb: makeDb(),
|
||||
postLikesDb: makeDb(),
|
||||
likesDb: makeDb(),
|
||||
followeeHeightsDb: makeDb(),
|
||||
statusDb: null,
|
||||
bchjs: { Address: { toHash160: (addr) => (addr === VIEWER ? MY_HASH : `hash-${addr}`) } }
|
||||
})
|
||||
}
|
||||
|
||||
function randomNotification () {
|
||||
return {
|
||||
blockHeight: intGen(rng, 0, 5000)(),
|
||||
seen: intGen(rng, 0, 1000)()
|
||||
}
|
||||
}
|
||||
|
||||
function fixtureGen () {
|
||||
return () => {
|
||||
const posts = new Map(VIEWER_POSTS.map((txid) => [txid, { addr: VIEWER }]))
|
||||
const follows = []
|
||||
const likeKeys = []
|
||||
const danglingLikes = []
|
||||
const children = []
|
||||
const childPosts = []
|
||||
const postsDbEntries = []
|
||||
const addrPostHeightEntries = []
|
||||
const postLikesEntries = []
|
||||
const likesEntries = []
|
||||
const postChildrenEntries = []
|
||||
const followeeHeightEntries = []
|
||||
|
||||
// Active follow records of me by other addresses.
|
||||
const nFollows = intGen(rng, 0, 6)()
|
||||
for (const txid of VIEWER_POSTS) {
|
||||
postsDbEntries.push([txid, { addr: VIEWER }])
|
||||
addrPostHeightEntries.push([`${VIEWER}:${pad(0)}:${txid}`, { txid, addr: VIEWER, blockHeight: 0 }])
|
||||
}
|
||||
|
||||
// Follows: random events, including repeated followers so the newest-wins
|
||||
// rule is exercised. Recorded in the expected order.
|
||||
const events = []
|
||||
const nFollows = intGen(rng, 0, 8)()
|
||||
for (let i = 0; i < nFollows; i++) {
|
||||
const follower = OTHERS[Math.floor(rng() * OTHERS.length)]
|
||||
const toMe = rng() < 0.7
|
||||
follows.push([
|
||||
`${follower}:${toMe ? MY_HASH : 'hash-other'}`,
|
||||
{
|
||||
followerAddr: follower,
|
||||
followeePkHash: toMe ? MY_HASH : 'hash-other',
|
||||
unfollow: rng() < 0.2,
|
||||
txid: 'follow' + i,
|
||||
// a fraction omit blockHeight/seen to exercise the ?? 0 defaults
|
||||
blockHeight: rng() < 0.2 ? undefined : intGen(rng, 0, 5000)(),
|
||||
seen: rng() < 0.2 ? undefined : intGen(rng, 0, 1000)()
|
||||
}
|
||||
const n = randomNotification()
|
||||
const unfollow = rng() < 0.25
|
||||
events.push({ follower, ...n, unfollow })
|
||||
followeeHeightEntries.push([
|
||||
`${MY_HASH}:${pad(n.blockHeight)}:${follower}`,
|
||||
{ followerAddr: follower, followeePkHash: MY_HASH, unfollow, txid: `follow${i}`, seen: n.seen, blockHeight: n.blockHeight }
|
||||
])
|
||||
}
|
||||
|
||||
// Likes on my posts by other addresses.
|
||||
const nLikes = intGen(rng, 0, 6)()
|
||||
// Likes on the viewer's posts by other addresses.
|
||||
const likeRecords = []
|
||||
const nLikes = intGen(rng, 0, 8)()
|
||||
for (let i = 0; i < nLikes; i++) {
|
||||
const postTxid = VIEWER_POSTS[Math.floor(rng() * VIEWER_POSTS.length)]
|
||||
likeKeys.push(['like' + i, {
|
||||
addr: OTHERS[Math.floor(rng() * OTHERS.length)],
|
||||
postTxid,
|
||||
blockHeight: intGen(rng, 0, 5000)(),
|
||||
seen: intGen(rng, 0, 1000)()
|
||||
}])
|
||||
const actor = OTHERS[Math.floor(rng() * OTHERS.length)]
|
||||
const n = randomNotification()
|
||||
const likeTxid = `like${i}`
|
||||
likeRecords.push({ likeTxid, addr: actor, ...n })
|
||||
postLikesEntries.push([`${postTxid}:${likeTxid}`, { postTxid, txid: likeTxid }])
|
||||
likesEntries.push([likeTxid, { addr: actor, postTxid, blockHeight: n.blockHeight, seen: n.seen }])
|
||||
}
|
||||
|
||||
// Likes whose target post is missing, to exercise the exclusion path.
|
||||
const nDangling = intGen(rng, 0, 3)()
|
||||
for (let i = 0; i < nDangling; i++) {
|
||||
danglingLikes.push(['dl' + i, { addr: OTHERS[0], postTxid: 'missing-post', blockHeight: 1, seen: 1 }])
|
||||
}
|
||||
const allLikes = likeKeys.concat(danglingLikes)
|
||||
|
||||
// Replies to my posts by other addresses.
|
||||
const nReplies = intGen(rng, 0, 6)()
|
||||
// Replies to the viewer's posts by other addresses.
|
||||
const replyRecords = []
|
||||
const nReplies = intGen(rng, 0, 8)()
|
||||
for (let i = 0; i < nReplies; i++) {
|
||||
const parentTxid = VIEWER_POSTS[Math.floor(rng() * VIEWER_POSTS.length)]
|
||||
const childTxid = 'child' + i
|
||||
const childPost = {
|
||||
addr: OTHERS[Math.floor(rng() * OTHERS.length)],
|
||||
blockHeight: intGen(rng, 0, 5000)(),
|
||||
seen: intGen(rng, 0, 1000)()
|
||||
}
|
||||
childPosts.push(childTxid)
|
||||
posts.set(childTxid, childPost)
|
||||
children.push([`${parentTxid}:${childTxid}`, { parentTxid, childTxid }])
|
||||
const childTxid = `child${i}`
|
||||
const actor = OTHERS[Math.floor(rng() * OTHERS.length)]
|
||||
const n = randomNotification()
|
||||
replyRecords.push({ childTxid, addr: actor, ...n })
|
||||
postChildrenEntries.push([`${parentTxid}:${childTxid}`, { parentTxid, childTxid, blockHeight: n.blockHeight }])
|
||||
postsDbEntries.push([childTxid, { addr: actor, text: 'reply', blockHeight: n.blockHeight, seen: n.seen }])
|
||||
}
|
||||
|
||||
return {
|
||||
query: buildQuery({ posts, follows, likes: allLikes, children }),
|
||||
follows,
|
||||
likeKeys,
|
||||
childPosts,
|
||||
posts,
|
||||
events,
|
||||
likeRecords,
|
||||
replyRecords,
|
||||
postsDbEntries,
|
||||
addrPostHeightEntries,
|
||||
postLikesEntries,
|
||||
likesEntries,
|
||||
postChildrenEntries,
|
||||
followeeHeightEntries,
|
||||
limit: intGen(rng, 1, 8)(),
|
||||
offset: intGen(rng, 0, 10)()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildExpected ({ follows, likeKeys, childPosts, posts, limit, offset }) {
|
||||
// Build the adapter with the generated stores populated.
|
||||
function populate (input) {
|
||||
const query = buildQuery()
|
||||
input.postsDbEntries.forEach(([key, value]) => query.postsDb.map.set(key, value))
|
||||
input.addrPostHeightEntries.forEach(([key, value]) => query.addrPostHeightsDb.map.set(key, value))
|
||||
input.postLikesEntries.forEach(([key, value]) => query.postLikesDb.map.set(key, value))
|
||||
input.likesEntries.forEach(([key, value]) => query.likesDb.map.set(key, value))
|
||||
input.postChildrenEntries.forEach(([key, value]) => query.postChildrenDb.map.set(key, value))
|
||||
input.followeeHeightEntries.forEach(([key, value]) => query.followeeHeightsDb.map.set(key, value))
|
||||
return query
|
||||
}
|
||||
|
||||
// Mirror the adapter's window-free aggregation without using the adapter.
|
||||
function buildExpected ({ events, likeRecords, replyRecords, limit, offset }) {
|
||||
const out = []
|
||||
|
||||
for (const [, record] of follows) {
|
||||
if (record.unfollow === true) continue
|
||||
if (record.followeePkHash !== MY_HASH) continue
|
||||
if (record.followerAddr === VIEWER) continue
|
||||
out.push({ blockHeight: record.blockHeight ?? 0, seen: record.seen ?? 0 })
|
||||
// Newest follow event per follower wins.
|
||||
const newestByFollower = new Map()
|
||||
for (const event of [...events].sort((a, b) => a.blockHeight - b.blockHeight)) {
|
||||
newestByFollower.set(event.follower, event)
|
||||
}
|
||||
for (const event of newestByFollower.values()) {
|
||||
if (event.unfollow) continue
|
||||
out.push({ blockHeight: event.blockHeight, seen: event.seen })
|
||||
}
|
||||
|
||||
for (const [, like] of likeKeys) {
|
||||
if (!like || like.addr === VIEWER) continue
|
||||
const post = posts.get(like.postTxid)
|
||||
if (!post || post.addr !== VIEWER) continue
|
||||
out.push({ blockHeight: like.blockHeight ?? 0, seen: like.seen ?? 0 })
|
||||
}
|
||||
|
||||
for (const childTxid of childPosts) {
|
||||
// Parent and child both exist in the fixture; child is always authored by
|
||||
// a non-viewer, parent by the viewer, so every child here qualifies.
|
||||
out.push({ blockHeight: posts.get(childTxid).blockHeight ?? 0, seen: posts.get(childTxid).seen ?? 0 })
|
||||
}
|
||||
for (const like of likeRecords) out.push({ blockHeight: like.blockHeight, seen: like.seen })
|
||||
for (const reply of replyRecords) out.push({ blockHeight: reply.blockHeight, seen: reply.seen })
|
||||
|
||||
out.sort((a, b) => {
|
||||
if (b.blockHeight !== a.blockHeight) return b.blockHeight - a.blockHeight
|
||||
return (b.seen ?? 0) - (a.seen ?? 0)
|
||||
})
|
||||
|
||||
return {
|
||||
total: out.length,
|
||||
page: out.slice(offset, offset + limit)
|
||||
}
|
||||
return { total: out.length, page: out.slice(offset, offset + limit) }
|
||||
}
|
||||
|
||||
test('notifications are sorted newest-first with seen tie-break and exact pagination', async () => {
|
||||
await forAll(
|
||||
fixtureGen(),
|
||||
async ({ query, follows, likeKeys, childPosts, posts, limit, offset }) => {
|
||||
const { notifications, total } = await query.listNotifications(VIEWER, { limit, offset })
|
||||
const expected = buildExpected({ follows, likeKeys, childPosts, posts, limit, offset })
|
||||
async (input) => {
|
||||
const query = populate(input)
|
||||
const { notifications, total } = await query.listNotifications(VIEWER, {
|
||||
limit: input.limit,
|
||||
offset: input.offset
|
||||
})
|
||||
const expected = buildExpected(input)
|
||||
|
||||
if (total !== expected.total) return false
|
||||
if (notifications.length !== expected.page.length) return false
|
||||
@@ -193,8 +209,12 @@ test('notifications are sorted newest-first with seen tie-break and exact pagina
|
||||
test('the returned page is globally ordered by blockHeight then seen descending', async () => {
|
||||
await forAll(
|
||||
fixtureGen(),
|
||||
async ({ query, limit, offset }) => {
|
||||
const { notifications } = await query.listNotifications(VIEWER, { limit, offset })
|
||||
async (input) => {
|
||||
const query = populate(input)
|
||||
const { notifications } = await query.listNotifications(VIEWER, {
|
||||
limit: input.limit,
|
||||
offset: input.offset
|
||||
})
|
||||
for (let i = 1; i < notifications.length; i++) {
|
||||
const prev = notifications[i - 1]
|
||||
const cur = notifications[i]
|
||||
|
||||
@@ -1,526 +1,350 @@
|
||||
import { assert } from 'chai'
|
||||
import sinon from 'sinon'
|
||||
import NotificationsQuery from '../../../src/adapters/notifications-query.js'
|
||||
import { getPostOrNull } from '../../../src/adapters/lib/get-post-or-null.js'
|
||||
|
||||
describe('#NotificationsQuery', () => {
|
||||
let sandbox
|
||||
let postsDb
|
||||
let postChildrenDb
|
||||
let likesDb
|
||||
let followsDb
|
||||
let bchjs
|
||||
let uut
|
||||
|
||||
const MY_ADDR = 'bitcoincash:qqlrzp23w08434twtmvr4fxw672whkjy0py26r63g3d'
|
||||
const THEIR_ADDR = 'bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy'
|
||||
const MY_HASH160 = 'myhash160'
|
||||
const THEIR_HASH160 = 'theirhash160'
|
||||
const VIEWER = 'bitcoincash:viewer'
|
||||
const VIEWER_HASH = 'hash-viewer'
|
||||
const FOLLOWER = 'bitcoincash:follower'
|
||||
const OTHER = 'bitcoincash:other'
|
||||
const LIKER = 'bitcoincash:liker'
|
||||
const REPLIER = 'bitcoincash:replier'
|
||||
|
||||
function makeIterator (items) {
|
||||
return (async function * () {
|
||||
for (const item of items) yield item
|
||||
}())
|
||||
const HEIGHT_PAD = 12
|
||||
const pad = (h) => String(h).padStart(HEIGHT_PAD, '0')
|
||||
|
||||
// A minimal LevelDB double that honors gte/lte range options and records
|
||||
// iteration calls so tests can assert that a store was never scanned.
|
||||
function makeDb (entries = []) {
|
||||
const map = new Map(entries)
|
||||
const iteratorCalls = []
|
||||
return {
|
||||
map,
|
||||
iteratorCalls,
|
||||
async get (key) {
|
||||
if (!map.has(key)) {
|
||||
const err = new Error('not found')
|
||||
err.notFound = true
|
||||
throw err
|
||||
}
|
||||
return map.get(key)
|
||||
},
|
||||
iterator (opts = {}) {
|
||||
iteratorCalls.push(opts)
|
||||
let keys = [...map.keys()].sort()
|
||||
if (opts.gte !== undefined) keys = keys.filter((key) => key >= opts.gte)
|
||||
if (opts.lte !== undefined) keys = keys.filter((key) => key <= opts.lte)
|
||||
return (async function * () {
|
||||
for (const key of keys) yield [key, map.get(key)]
|
||||
}())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addrPostHeight (addr, height, txid) {
|
||||
return [`${addr}:${pad(height)}:${txid}`, { txid, addr, blockHeight: height }]
|
||||
}
|
||||
|
||||
function postLike (postTxid, likeTxid) {
|
||||
return [`${postTxid}:${likeTxid}`, { postTxid, txid: likeTxid }]
|
||||
}
|
||||
|
||||
function child (parentTxid, childTxid, blockHeight) {
|
||||
return [`${parentTxid}:${childTxid}`, { parentTxid, childTxid, blockHeight }]
|
||||
}
|
||||
|
||||
function followeeHeight (followeeHash, height, follower, unfollow = false) {
|
||||
return [
|
||||
`${followeeHash}:${pad(height)}:${follower}`,
|
||||
{ followerAddr: follower, followeePkHash: followeeHash, unfollow, txid: `follow-${height}`, seen: height, blockHeight: height }
|
||||
]
|
||||
}
|
||||
|
||||
function buildQuery (overrides = {}) {
|
||||
const postsDb = overrides.postsDb || makeDb()
|
||||
const addrPostHeightsDb = overrides.addrPostHeightsDb || makeDb()
|
||||
const postChildrenDb = overrides.postChildrenDb || makeDb()
|
||||
const postLikesDb = overrides.postLikesDb || makeDb()
|
||||
const likesDb = overrides.likesDb || makeDb()
|
||||
const followeeHeightsDb = overrides.followeeHeightsDb || makeDb()
|
||||
const statusDb = 'statusDb' in overrides
|
||||
? overrides.statusDb
|
||||
: makeDb([['status', { chainBlockHeight: 700000 }]])
|
||||
|
||||
const bchjs = {
|
||||
Address: {
|
||||
toHash160: (addr) => (addr === VIEWER ? VIEWER_HASH : `hash-${addr}`)
|
||||
}
|
||||
}
|
||||
|
||||
return new NotificationsQuery({
|
||||
postsDb,
|
||||
addrPostHeightsDb,
|
||||
postChildrenDb,
|
||||
postLikesDb,
|
||||
likesDb,
|
||||
followeeHeightsDb,
|
||||
statusDb,
|
||||
notificationBlockWindow: overrides.notificationBlockWindow,
|
||||
muteQuery: overrides.muteQuery,
|
||||
bchjs
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
sandbox = sinon.createSandbox()
|
||||
|
||||
postsDb = { get: sandbox.stub() }
|
||||
postChildrenDb = { iterator: sandbox.stub() }
|
||||
likesDb = { iterator: sandbox.stub() }
|
||||
followsDb = { iterator: sandbox.stub() }
|
||||
|
||||
bchjs = {
|
||||
Address: {
|
||||
toHash160: sandbox.stub()
|
||||
}
|
||||
}
|
||||
|
||||
bchjs.Address.toHash160.withArgs(MY_ADDR).returns(MY_HASH160)
|
||||
bchjs.Address.toHash160.withArgs(THEIR_ADDR).returns(THEIR_HASH160)
|
||||
|
||||
uut = new NotificationsQuery({
|
||||
postsDb,
|
||||
postParentsDb: {},
|
||||
postChildrenDb,
|
||||
likesDb,
|
||||
postLikesDb: {},
|
||||
followsDb,
|
||||
bchjs
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => sandbox.restore())
|
||||
|
||||
it('should throw when postsDb is missing', () => {
|
||||
try {
|
||||
// eslint-disable-next-line no-new
|
||||
new NotificationsQuery({ postChildrenDb, likesDb, followsDb, bchjs })
|
||||
assert.fail('Expected error')
|
||||
} catch (err) {
|
||||
assert.include(err.message, 'postsDb required')
|
||||
it('should throw when required stores are missing', () => {
|
||||
const cases = [
|
||||
['postsDb', /postsDb required/],
|
||||
['addrPostHeightsDb', /addrPostHeightsDb required/],
|
||||
['postChildrenDb', /postChildrenDb required/],
|
||||
['postLikesDb', /postLikesDb required/],
|
||||
['likesDb', /likesDb required/],
|
||||
['followeeHeightsDb', /followeeHeightsDb required/]
|
||||
]
|
||||
|
||||
for (const [omit, expected] of cases) {
|
||||
const config = {
|
||||
postsDb: makeDb(),
|
||||
addrPostHeightsDb: makeDb(),
|
||||
postChildrenDb: makeDb(),
|
||||
postLikesDb: makeDb(),
|
||||
likesDb: makeDb(),
|
||||
followeeHeightsDb: makeDb()
|
||||
}
|
||||
delete config[omit]
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line no-new
|
||||
new NotificationsQuery(config)
|
||||
assert.fail(`Expected error for missing ${omit}`)
|
||||
} catch (err) {
|
||||
assert.match(err.message, expected)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('should throw when postChildrenDb is missing', () => {
|
||||
try {
|
||||
// eslint-disable-next-line no-new
|
||||
new NotificationsQuery({ postsDb, postParentsDb: {}, likesDb, postLikesDb: {}, followsDb, bchjs })
|
||||
assert.fail('Expected error')
|
||||
} catch (err) {
|
||||
assert.include(err.message, 'postChildrenDb required')
|
||||
}
|
||||
})
|
||||
it('should include an in-window follow, reply, and like sorted newest-first', async () => {
|
||||
const postsDb = makeDb([
|
||||
['post-recent', { addr: VIEWER, text: 'hi' }],
|
||||
['reply-recent', { addr: REPLIER, text: 'reply', blockHeight: 690150, seen: 1 }]
|
||||
])
|
||||
const addrPostHeightsDb = makeDb([addrPostHeight(VIEWER, 690000, 'post-recent')])
|
||||
const postLikesDb = makeDb([postLike('post-recent', 'like-recent')])
|
||||
const likesDb = makeDb([['like-recent', { addr: LIKER, postTxid: 'post-recent', blockHeight: 690100, seen: 2 }]])
|
||||
const postChildrenDb = makeDb([child('post-recent', 'reply-recent', 690150)])
|
||||
const followeeHeightsDb = makeDb([followeeHeight(VIEWER_HASH, 690400, FOLLOWER)])
|
||||
|
||||
it('should throw when likesDb is missing', () => {
|
||||
try {
|
||||
// eslint-disable-next-line no-new
|
||||
new NotificationsQuery({ postsDb, postParentsDb: {}, postChildrenDb, postLikesDb: {}, followsDb, bchjs })
|
||||
assert.fail('Expected error')
|
||||
} catch (err) {
|
||||
assert.include(err.message, 'likesDb required')
|
||||
}
|
||||
})
|
||||
|
||||
it('should throw when followsDb is missing', () => {
|
||||
try {
|
||||
// eslint-disable-next-line no-new
|
||||
new NotificationsQuery({ postsDb, postParentsDb: {}, postChildrenDb, likesDb, postLikesDb: {}, bchjs })
|
||||
assert.fail('Expected error')
|
||||
} catch (err) {
|
||||
assert.include(err.message, 'followsDb required')
|
||||
}
|
||||
})
|
||||
|
||||
it('should throw when postParentsDb is missing', () => {
|
||||
try {
|
||||
// eslint-disable-next-line no-new
|
||||
new NotificationsQuery({ postsDb, postChildrenDb, likesDb, postLikesDb: {}, followsDb, bchjs })
|
||||
assert.fail('Expected error')
|
||||
} catch (err) {
|
||||
assert.include(err.message, 'postParentsDb required')
|
||||
}
|
||||
})
|
||||
|
||||
it('should throw when postLikesDb is missing', () => {
|
||||
try {
|
||||
// eslint-disable-next-line no-new
|
||||
new NotificationsQuery({ postsDb, postParentsDb: {}, postChildrenDb, likesDb, followsDb, bchjs })
|
||||
assert.fail('Expected error')
|
||||
} catch (err) {
|
||||
assert.include(err.message, 'postLikesDb required')
|
||||
}
|
||||
})
|
||||
|
||||
it('should exclude notifications from muted addresses when a mute query is provided', async () => {
|
||||
const myPostTxid = 'a'.repeat(64)
|
||||
const replyTxid = 'b'.repeat(64)
|
||||
const likeTxid = 'c'.repeat(64)
|
||||
|
||||
postChildrenDb.iterator.returns(makeIterator([
|
||||
[`${myPostTxid}:${replyTxid}`, { parentTxid: myPostTxid, childTxid: replyTxid, blockHeight: 100 }]
|
||||
]))
|
||||
likesDb.iterator.returns(makeIterator([
|
||||
[likeTxid, { addr: THEIR_ADDR, postTxid: myPostTxid, blockHeight: 300 }]
|
||||
]))
|
||||
followsDb.iterator.returns(makeIterator([]))
|
||||
|
||||
postsDb.get.withArgs(myPostTxid).resolves({ addr: MY_ADDR, text: 'hello' })
|
||||
postsDb.get.withArgs(replyTxid).resolves({ addr: THEIR_ADDR, text: 'reply' })
|
||||
|
||||
const muteQuery = {
|
||||
listMuted: sandbox.stub().resolves([THEIR_ADDR])
|
||||
}
|
||||
uut = new NotificationsQuery({
|
||||
postsDb,
|
||||
postParentsDb: {},
|
||||
postChildrenDb,
|
||||
likesDb,
|
||||
postLikesDb: {},
|
||||
followsDb,
|
||||
muteQuery,
|
||||
bchjs
|
||||
})
|
||||
|
||||
const result = await uut.listNotifications(MY_ADDR, { limit: 100, offset: 0 })
|
||||
|
||||
assert.equal(result.total, 0)
|
||||
assert.isTrue(muteQuery.listMuted.calledOnceWith(MY_ADDR))
|
||||
})
|
||||
|
||||
it('should return null from getPostOrNull when the post is not found', async () => {
|
||||
const missingTxid = 'c'.repeat(64)
|
||||
const notFound = new Error('not found')
|
||||
notFound.notFound = true
|
||||
postsDb.get.withArgs(missingTxid).rejects(notFound)
|
||||
|
||||
const result = await getPostOrNull(postsDb, missingTxid)
|
||||
assert.equal(result, null)
|
||||
})
|
||||
|
||||
it('should rethrow non-notFound errors from getPostOrNull', async () => {
|
||||
const txid = 'd'.repeat(64)
|
||||
const boom = new Error('boom')
|
||||
postsDb.get.withArgs(txid).rejects(boom)
|
||||
|
||||
try {
|
||||
await getPostOrNull(postsDb, txid)
|
||||
assert.fail('Expected error')
|
||||
} catch (err) {
|
||||
assert.equal(err, boom)
|
||||
}
|
||||
})
|
||||
|
||||
it('should include a reply to my post', async () => {
|
||||
const myPostTxid = 'a'.repeat(64)
|
||||
const replyTxid = 'b'.repeat(64)
|
||||
|
||||
postChildrenDb.iterator.returns(makeIterator([
|
||||
[`${myPostTxid}:${replyTxid}`, { parentTxid: myPostTxid, childTxid: replyTxid, blockHeight: 200 }]
|
||||
]))
|
||||
likesDb.iterator.returns(makeIterator([]))
|
||||
followsDb.iterator.returns(makeIterator([]))
|
||||
|
||||
postsDb.get.withArgs(myPostTxid).resolves({ addr: MY_ADDR, text: 'hello' })
|
||||
postsDb.get.withArgs(replyTxid).resolves({ addr: THEIR_ADDR, text: 'nice post', blockHeight: 200 })
|
||||
|
||||
const result = await uut.listNotifications(MY_ADDR, { limit: 100, offset: 0 })
|
||||
|
||||
assert.equal(result.total, 1)
|
||||
assert.equal(result.notifications.length, 1)
|
||||
const n = result.notifications[0]
|
||||
assert.equal(n.type, 'reply')
|
||||
assert.equal(n.txid, replyTxid)
|
||||
assert.equal(n.addr, THEIR_ADDR)
|
||||
assert.equal(n.postTxid, myPostTxid)
|
||||
assert.equal(n.text, 'nice post')
|
||||
})
|
||||
|
||||
it('should include a like on my post', async () => {
|
||||
const myPostTxid = 'a'.repeat(64)
|
||||
const likeTxid = 'b'.repeat(64)
|
||||
|
||||
postChildrenDb.iterator.returns(makeIterator([]))
|
||||
followsDb.iterator.returns(makeIterator([]))
|
||||
likesDb.iterator.returns(makeIterator([
|
||||
[likeTxid, { addr: THEIR_ADDR, postTxid: myPostTxid, blockHeight: 300 }]
|
||||
]))
|
||||
|
||||
postsDb.get.withArgs(myPostTxid).resolves({ addr: MY_ADDR, text: 'hello' })
|
||||
|
||||
const result = await uut.listNotifications(MY_ADDR, { limit: 100, offset: 0 })
|
||||
|
||||
assert.equal(result.total, 1)
|
||||
const n = result.notifications[0]
|
||||
assert.equal(n.type, 'like')
|
||||
assert.equal(n.txid, likeTxid)
|
||||
assert.equal(n.addr, THEIR_ADDR)
|
||||
assert.equal(n.postTxid, myPostTxid)
|
||||
})
|
||||
|
||||
it('should include a follow of me', async () => {
|
||||
postChildrenDb.iterator.returns(makeIterator([]))
|
||||
likesDb.iterator.returns(makeIterator([]))
|
||||
followsDb.iterator.returns(makeIterator([
|
||||
[`${THEIR_ADDR}:${MY_HASH160}`, { followerAddr: THEIR_ADDR, followeePkHash: MY_HASH160, unfollow: false, txid: 'c'.repeat(64), blockHeight: 150 }]
|
||||
]))
|
||||
|
||||
const result = await uut.listNotifications(MY_ADDR, { limit: 100, offset: 0 })
|
||||
|
||||
assert.equal(result.total, 1)
|
||||
const n = result.notifications[0]
|
||||
assert.equal(n.type, 'follow')
|
||||
assert.equal(n.txid, 'c'.repeat(64))
|
||||
assert.equal(n.addr, THEIR_ADDR)
|
||||
})
|
||||
|
||||
it('should fall back to the follow key first component when followerAddr is missing', async () => {
|
||||
postChildrenDb.iterator.returns(makeIterator([]))
|
||||
likesDb.iterator.returns(makeIterator([]))
|
||||
// A follow record can omit followerAddr; the address must be derived from
|
||||
// the first component of the `addr:<followeePkHash>` key.
|
||||
followsDb.iterator.returns(makeIterator([
|
||||
[`${THEIR_ADDR}:${MY_HASH160}`, { followeePkHash: MY_HASH160, unfollow: false, txid: 'c'.repeat(64), blockHeight: 150 }]
|
||||
]))
|
||||
|
||||
const result = await uut.listNotifications(MY_ADDR, { limit: 100, offset: 0 })
|
||||
|
||||
assert.equal(result.total, 1)
|
||||
const n = result.notifications[0]
|
||||
assert.equal(n.type, 'follow')
|
||||
assert.equal(n.addr, THEIR_ADDR)
|
||||
})
|
||||
|
||||
it('should exclude my own replies', async () => {
|
||||
const myPostTxid = 'a'.repeat(64)
|
||||
const replyTxid = 'b'.repeat(64)
|
||||
|
||||
postChildrenDb.iterator.returns(makeIterator([
|
||||
[`${myPostTxid}:${replyTxid}`, { parentTxid: myPostTxid, childTxid: replyTxid, blockHeight: 100 }]
|
||||
]))
|
||||
likesDb.iterator.returns(makeIterator([]))
|
||||
followsDb.iterator.returns(makeIterator([]))
|
||||
|
||||
postsDb.get.withArgs(myPostTxid).resolves({ addr: MY_ADDR, text: 'hello' })
|
||||
postsDb.get.withArgs(replyTxid).resolves({ addr: MY_ADDR, text: 'my own reply' })
|
||||
|
||||
const result = await uut.listNotifications(MY_ADDR, { limit: 100, offset: 0 })
|
||||
|
||||
assert.equal(result.total, 0)
|
||||
})
|
||||
|
||||
it('should exclude replies to posts by other people', async () => {
|
||||
const theirPostTxid = 'a'.repeat(64)
|
||||
const replyTxid = 'b'.repeat(64)
|
||||
|
||||
postChildrenDb.iterator.returns(makeIterator([
|
||||
[`${theirPostTxid}:${replyTxid}`, { parentTxid: theirPostTxid, childTxid: replyTxid, blockHeight: 100 }]
|
||||
]))
|
||||
likesDb.iterator.returns(makeIterator([]))
|
||||
followsDb.iterator.returns(makeIterator([]))
|
||||
|
||||
postsDb.get.withArgs(theirPostTxid).resolves({ addr: THEIR_ADDR, text: 'alice post' })
|
||||
postsDb.get.withArgs(replyTxid).resolves({ addr: 'bitcoincash:other', text: 'reply' })
|
||||
|
||||
const result = await uut.listNotifications(MY_ADDR, { limit: 100, offset: 0 })
|
||||
|
||||
assert.equal(result.total, 0)
|
||||
})
|
||||
|
||||
it('should exclude follows of other people', async () => {
|
||||
postChildrenDb.iterator.returns(makeIterator([]))
|
||||
likesDb.iterator.returns(makeIterator([]))
|
||||
followsDb.iterator.returns(makeIterator([
|
||||
[`${THEIR_ADDR}:${'otherhash'}`, { followerAddr: THEIR_ADDR, followeePkHash: 'otherhash', unfollow: false, txid: 'c'.repeat(64), blockHeight: 150 }]
|
||||
]))
|
||||
|
||||
const result = await uut.listNotifications(MY_ADDR, { limit: 100, offset: 0 })
|
||||
|
||||
assert.equal(result.total, 0)
|
||||
})
|
||||
|
||||
it('should exclude unfollows', async () => {
|
||||
postChildrenDb.iterator.returns(makeIterator([]))
|
||||
likesDb.iterator.returns(makeIterator([]))
|
||||
followsDb.iterator.returns(makeIterator([
|
||||
[`${THEIR_ADDR}:${MY_HASH160}`, { followerAddr: THEIR_ADDR, followeePkHash: MY_HASH160, unfollow: true, txid: 'c'.repeat(64), blockHeight: 150 }]
|
||||
]))
|
||||
|
||||
const result = await uut.listNotifications(MY_ADDR, { limit: 100, offset: 0 })
|
||||
|
||||
assert.equal(result.total, 0)
|
||||
})
|
||||
|
||||
it('should sort notifications by block height descending', async () => {
|
||||
const myPostTxid = 'a'.repeat(64)
|
||||
const replyTxid = 'b'.repeat(64)
|
||||
const likeTxid = 'c'.repeat(64)
|
||||
|
||||
postChildrenDb.iterator.returns(makeIterator([
|
||||
[`${myPostTxid}:${replyTxid}`, { parentTxid: myPostTxid, childTxid: replyTxid, blockHeight: 100 }]
|
||||
]))
|
||||
likesDb.iterator.returns(makeIterator([
|
||||
[likeTxid, { addr: THEIR_ADDR, postTxid: myPostTxid, blockHeight: 300 }]
|
||||
]))
|
||||
followsDb.iterator.returns(makeIterator([]))
|
||||
|
||||
postsDb.get.withArgs(myPostTxid).resolves({ addr: MY_ADDR, text: 'hello' })
|
||||
postsDb.get.withArgs(replyTxid).resolves({ addr: THEIR_ADDR, text: 'reply' })
|
||||
|
||||
const result = await uut.listNotifications(MY_ADDR, { limit: 100, offset: 0 })
|
||||
|
||||
assert.equal(result.total, 2)
|
||||
assert.equal(result.notifications[0].type, 'like')
|
||||
assert.equal(result.notifications[1].type, 'reply')
|
||||
})
|
||||
|
||||
it('should sort three notifications by block height descending', async () => {
|
||||
const myPostTxid = 'a'.repeat(64)
|
||||
const replyTxid = 'b'.repeat(64)
|
||||
const likeTxid = 'c'.repeat(64)
|
||||
|
||||
postChildrenDb.iterator.returns(makeIterator([
|
||||
[`${myPostTxid}:${replyTxid}`, { parentTxid: myPostTxid, childTxid: replyTxid, blockHeight: 200 }]
|
||||
]))
|
||||
likesDb.iterator.returns(makeIterator([
|
||||
[likeTxid, { addr: THEIR_ADDR, postTxid: myPostTxid, blockHeight: 300 }]
|
||||
]))
|
||||
followsDb.iterator.returns(makeIterator([
|
||||
[`${THEIR_ADDR}:${MY_HASH160}`, { followerAddr: THEIR_ADDR, followeePkHash: MY_HASH160, unfollow: false, txid: 'd'.repeat(64), blockHeight: 100 }]
|
||||
]))
|
||||
|
||||
postsDb.get.withArgs(myPostTxid).resolves({ addr: MY_ADDR, text: 'hello' })
|
||||
postsDb.get.withArgs(replyTxid).resolves({ addr: THEIR_ADDR, text: 'reply' })
|
||||
|
||||
const result = await uut.listNotifications(MY_ADDR, { limit: 100, offset: 0 })
|
||||
const uut = buildQuery({ postsDb, addrPostHeightsDb, postLikesDb, likesDb, postChildrenDb, followeeHeightsDb })
|
||||
const result = await uut.listNotifications(VIEWER, { limit: 100, offset: 0 })
|
||||
|
||||
assert.equal(result.total, 3)
|
||||
assert.equal(result.notifications[0].type, 'like')
|
||||
assert.equal(result.notifications[0].blockHeight, 300)
|
||||
assert.equal(result.notifications[1].type, 'reply')
|
||||
assert.equal(result.notifications[1].blockHeight, 200)
|
||||
assert.equal(result.notifications[2].type, 'follow')
|
||||
assert.equal(result.notifications[2].blockHeight, 100)
|
||||
assert.deepEqual(result.notifications.map((n) => n.type), ['follow', 'reply', 'like'])
|
||||
assert.equal(result.notifications[0].addr, FOLLOWER)
|
||||
assert.equal(result.notifications[0].blockHeight, 690400)
|
||||
assert.equal(result.notifications[1].addr, REPLIER)
|
||||
assert.equal(result.notifications[2].addr, LIKER)
|
||||
})
|
||||
|
||||
it('should paginate notifications', async () => {
|
||||
const myPostTxid = 'a'.repeat(64)
|
||||
const replyTxid = 'b'.repeat(64)
|
||||
const likeTxid = 'c'.repeat(64)
|
||||
it('should exclude interactions with posts older than the window', async () => {
|
||||
const postsDb = makeDb([
|
||||
['post-old', { addr: VIEWER, text: 'old' }],
|
||||
['reply-old', { addr: REPLIER, text: 'reply', blockHeight: 690250, seen: 1 }]
|
||||
])
|
||||
const addrPostHeightsDb = makeDb([addrPostHeight(VIEWER, 600000, 'post-old')])
|
||||
const postLikesDb = makeDb([postLike('post-old', 'like-old')])
|
||||
const likesDb = makeDb([['like-old', { addr: LIKER, postTxid: 'post-old', blockHeight: 690200, seen: 1 }]])
|
||||
const postChildrenDb = makeDb([child('post-old', 'reply-old', 690250)])
|
||||
const followeeHeightsDb = makeDb([followeeHeight(VIEWER_HASH, 690400, FOLLOWER)])
|
||||
|
||||
postChildrenDb.iterator.returns(makeIterator([
|
||||
[`${myPostTxid}:${replyTxid}`, { parentTxid: myPostTxid, childTxid: replyTxid, blockHeight: 100 }]
|
||||
]))
|
||||
likesDb.iterator.returns(makeIterator([
|
||||
[likeTxid, { addr: THEIR_ADDR, postTxid: myPostTxid, blockHeight: 300 }]
|
||||
]))
|
||||
followsDb.iterator.returns(makeIterator([]))
|
||||
const uut = buildQuery({ postsDb, addrPostHeightsDb, postLikesDb, likesDb, postChildrenDb, followeeHeightsDb })
|
||||
const result = await uut.listNotifications(VIEWER, { limit: 100, offset: 0 })
|
||||
|
||||
postsDb.get.withArgs(myPostTxid).resolves({ addr: MY_ADDR, text: 'hello' })
|
||||
postsDb.get.withArgs(replyTxid).resolves({ addr: THEIR_ADDR, text: 'reply' })
|
||||
// The old post's like and reply are excluded; only the in-window follow remains.
|
||||
assert.equal(result.total, 1)
|
||||
assert.deepEqual(result.notifications.map((n) => n.type), ['follow'])
|
||||
})
|
||||
|
||||
const result = await uut.listNotifications(MY_ADDR, { limit: 1, offset: 0 })
|
||||
it('should include an out-of-window post when the window reaches it', async () => {
|
||||
const postsDb = makeDb([['post-old', { addr: VIEWER, text: 'old' }]])
|
||||
const addrPostHeightsDb = makeDb([addrPostHeight(VIEWER, 600000, 'post-old')])
|
||||
const postLikesDb = makeDb([postLike('post-old', 'like-old')])
|
||||
const likesDb = makeDb([['like-old', { addr: LIKER, postTxid: 'post-old', blockHeight: 690200, seen: 1 }]])
|
||||
|
||||
const uut = buildQuery({
|
||||
postsDb,
|
||||
addrPostHeightsDb,
|
||||
postLikesDb,
|
||||
likesDb,
|
||||
postChildrenDb: makeDb(),
|
||||
followeeHeightsDb: makeDb(),
|
||||
notificationBlockWindow: 100000
|
||||
})
|
||||
const result = await uut.listNotifications(VIEWER, { limit: 100, offset: 0 })
|
||||
|
||||
assert.equal(result.total, 1)
|
||||
assert.equal(result.notifications[0].blockHeight, 690200)
|
||||
})
|
||||
|
||||
it('should drop a follower whose newest entry is an unfollow', async () => {
|
||||
const followeeHeightsDb = makeDb([
|
||||
followeeHeight(VIEWER_HASH, 690550, FOLLOWER, false),
|
||||
followeeHeight(VIEWER_HASH, 690600, FOLLOWER, true)
|
||||
])
|
||||
|
||||
const uut = buildQuery({ followeeHeightsDb })
|
||||
const result = await uut.listNotifications(VIEWER, { limit: 100, offset: 0 })
|
||||
|
||||
assert.equal(result.total, 0)
|
||||
})
|
||||
|
||||
it('should keep a follower whose newest entry is a follow after an older unfollow', async () => {
|
||||
const followeeHeightsDb = makeDb([
|
||||
followeeHeight(VIEWER_HASH, 690550, FOLLOWER, true),
|
||||
followeeHeight(VIEWER_HASH, 690600, FOLLOWER, false)
|
||||
])
|
||||
|
||||
const uut = buildQuery({ followeeHeightsDb })
|
||||
const result = await uut.listNotifications(VIEWER, { limit: 100, offset: 0 })
|
||||
|
||||
assert.equal(result.total, 1)
|
||||
assert.equal(result.notifications[0].addr, FOLLOWER)
|
||||
})
|
||||
|
||||
it('should exclude self-likes, self-replies, and self-follows', async () => {
|
||||
const postsDb = makeDb([
|
||||
['post-recent', { addr: VIEWER, text: 'hi' }],
|
||||
['reply-self', { addr: VIEWER, text: 'mine', blockHeight: 690150 }]
|
||||
])
|
||||
const addrPostHeightsDb = makeDb([addrPostHeight(VIEWER, 690000, 'post-recent')])
|
||||
const postLikesDb = makeDb([postLike('post-recent', 'like-self')])
|
||||
const likesDb = makeDb([['like-self', { addr: VIEWER, postTxid: 'post-recent', blockHeight: 690100 }]])
|
||||
const postChildrenDb = makeDb([child('post-recent', 'reply-self', 690150)])
|
||||
const followeeHeightsDb = makeDb([followeeHeight(VIEWER_HASH, 690400, VIEWER)])
|
||||
|
||||
const uut = buildQuery({ postsDb, addrPostHeightsDb, postLikesDb, likesDb, postChildrenDb, followeeHeightsDb })
|
||||
const result = await uut.listNotifications(VIEWER, { limit: 100, offset: 0 })
|
||||
|
||||
assert.equal(result.total, 0)
|
||||
})
|
||||
|
||||
it('should exclude notifications from muted addresses', async () => {
|
||||
const postsDb = makeDb([['reply-recent', { addr: OTHER, text: 'reply', blockHeight: 690150 }]])
|
||||
const addrPostHeightsDb = makeDb([addrPostHeight(VIEWER, 690000, 'post-recent')])
|
||||
const postChildrenDb = makeDb([child('post-recent', 'reply-recent', 690150)])
|
||||
const muteQuery = { listMuted: sandbox.stub().resolves([OTHER]) }
|
||||
|
||||
const uut = buildQuery({ postsDb, addrPostHeightsDb, postChildrenDb, muteQuery })
|
||||
const result = await uut.listNotifications(VIEWER, { limit: 100, offset: 0 })
|
||||
|
||||
assert.equal(result.total, 0)
|
||||
assert.isTrue(muteQuery.listMuted.calledOnceWith(VIEWER))
|
||||
})
|
||||
|
||||
it('should break sort ties by seen descending', async () => {
|
||||
const postsDb = makeDb([
|
||||
['reply-a', { addr: REPLIER, text: 'a', blockHeight: 690150, seen: 1 }],
|
||||
['reply-b', { addr: OTHER, text: 'b', blockHeight: 690150, seen: 2 }]
|
||||
])
|
||||
const addrPostHeightsDb = makeDb([addrPostHeight(VIEWER, 690000, 'post-recent')])
|
||||
const postChildrenDb = makeDb([
|
||||
child('post-recent', 'reply-a', 690150),
|
||||
child('post-recent', 'reply-b', 690150)
|
||||
])
|
||||
|
||||
const uut = buildQuery({ postsDb, addrPostHeightsDb, postChildrenDb })
|
||||
const result = await uut.listNotifications(VIEWER, { limit: 100, offset: 0 })
|
||||
|
||||
assert.equal(result.total, 2)
|
||||
assert.equal(result.notifications[0].seen, 2)
|
||||
assert.equal(result.notifications[1].seen, 1)
|
||||
})
|
||||
|
||||
it('should paginate and report the exact in-window total', async () => {
|
||||
const followeeHeightsDb = makeDb([
|
||||
followeeHeight(VIEWER_HASH, 690400, FOLLOWER),
|
||||
followeeHeight(VIEWER_HASH, 690300, OTHER)
|
||||
])
|
||||
|
||||
const uut = buildQuery({ followeeHeightsDb })
|
||||
const result = await uut.listNotifications(VIEWER, { limit: 1, offset: 1 })
|
||||
|
||||
assert.equal(result.total, 2)
|
||||
assert.equal(result.notifications.length, 1)
|
||||
assert.equal(result.notifications[0].type, 'like')
|
||||
assert.equal(result.notifications[0].blockHeight, 690300)
|
||||
})
|
||||
|
||||
it('should default follow blockHeight and seen to 0 when missing', async () => {
|
||||
postChildrenDb.iterator.returns(makeIterator([]))
|
||||
likesDb.iterator.returns(makeIterator([]))
|
||||
followsDb.iterator.returns(makeIterator([
|
||||
[`${THEIR_ADDR}:${MY_HASH160}`, { followerAddr: THEIR_ADDR, followeePkHash: MY_HASH160, unfollow: false, txid: 'c'.repeat(64) }]
|
||||
]))
|
||||
it('should never iterate the likes store', async () => {
|
||||
const postsDb = makeDb([['post-recent', { addr: VIEWER, text: 'hi' }]])
|
||||
const addrPostHeightsDb = makeDb([addrPostHeight(VIEWER, 690000, 'post-recent')])
|
||||
const postLikesDb = makeDb([postLike('post-recent', 'like-recent')])
|
||||
const likesDb = makeDb([['like-recent', { addr: LIKER, postTxid: 'post-recent', blockHeight: 690100 }]])
|
||||
const followeeHeightsDb = makeDb([followeeHeight(VIEWER_HASH, 690400, FOLLOWER)])
|
||||
likesDb.iterator = sandbox.stub().throws(new Error('likes store must not be iterated'))
|
||||
|
||||
const result = await uut.listNotifications(MY_ADDR, { limit: 100, offset: 0 })
|
||||
const uut = buildQuery({ postsDb, addrPostHeightsDb, postLikesDb, likesDb, postChildrenDb: makeDb(), followeeHeightsDb })
|
||||
const result = await uut.listNotifications(VIEWER, { limit: 100, offset: 0 })
|
||||
|
||||
assert.equal(result.notifications.length, 1)
|
||||
const n = result.notifications[0]
|
||||
assert.equal(n.type, 'follow')
|
||||
assert.equal(n.blockHeight, 0)
|
||||
assert.equal(n.seen, 0)
|
||||
assert.equal(result.total, 2)
|
||||
assert.equal(likesDb.iterator.callCount, 0)
|
||||
})
|
||||
|
||||
it('should default like blockHeight and seen to 0 when missing', async () => {
|
||||
const myPostTxid = 'a'.repeat(64)
|
||||
const likeTxid = 'b'.repeat(64)
|
||||
it('should treat the whole history as in-window when status is missing', async () => {
|
||||
const postsDb = makeDb([['post-old', { addr: VIEWER, text: 'old' }]])
|
||||
const addrPostHeightsDb = makeDb([addrPostHeight(VIEWER, 1, 'post-old')])
|
||||
const postLikesDb = makeDb([postLike('post-old', 'like-old')])
|
||||
const likesDb = makeDb([['like-old', { addr: LIKER, postTxid: 'post-old', blockHeight: 2 }]])
|
||||
|
||||
postChildrenDb.iterator.returns(makeIterator([]))
|
||||
followsDb.iterator.returns(makeIterator([]))
|
||||
likesDb.iterator.returns(makeIterator([
|
||||
[likeTxid, { addr: THEIR_ADDR, postTxid: myPostTxid }]
|
||||
]))
|
||||
postsDb.get.withArgs(myPostTxid).resolves({ addr: MY_ADDR, text: 'hello' })
|
||||
const uut = buildQuery({
|
||||
postsDb,
|
||||
addrPostHeightsDb,
|
||||
postLikesDb,
|
||||
likesDb,
|
||||
postChildrenDb: makeDb(),
|
||||
followeeHeightsDb: makeDb(),
|
||||
statusDb: null
|
||||
})
|
||||
const result = await uut.listNotifications(VIEWER, { limit: 100, offset: 0 })
|
||||
|
||||
const result = await uut.listNotifications(MY_ADDR, { limit: 100, offset: 0 })
|
||||
|
||||
assert.equal(result.notifications.length, 1)
|
||||
const n = result.notifications[0]
|
||||
assert.equal(n.type, 'like')
|
||||
assert.equal(n.blockHeight, 0)
|
||||
assert.equal(n.seen, 0)
|
||||
assert.equal(result.total, 1)
|
||||
})
|
||||
|
||||
it('should default reply blockHeight and seen to 0 when missing', async () => {
|
||||
const myPostTxid = 'a'.repeat(64)
|
||||
const replyTxid = 'b'.repeat(64)
|
||||
it('should recover the follower from the followeeHeights key when the record lacks it', async () => {
|
||||
const followeeHeightsDb = makeDb([
|
||||
[`${VIEWER_HASH}:${pad(690400)}:${FOLLOWER}`, { unfollow: false, txid: 't', blockHeight: 690400 }]
|
||||
])
|
||||
|
||||
postChildrenDb.iterator.returns(makeIterator([
|
||||
[`${myPostTxid}:${replyTxid}`, { parentTxid: myPostTxid, childTxid: replyTxid }]
|
||||
]))
|
||||
likesDb.iterator.returns(makeIterator([]))
|
||||
followsDb.iterator.returns(makeIterator([]))
|
||||
postsDb.get.withArgs(myPostTxid).resolves({ addr: MY_ADDR, text: 'hello' })
|
||||
postsDb.get.withArgs(replyTxid).resolves({ addr: THEIR_ADDR, text: 'reply' })
|
||||
const uut = buildQuery({ followeeHeightsDb })
|
||||
const result = await uut.listNotifications(VIEWER, { limit: 100, offset: 0 })
|
||||
|
||||
const result = await uut.listNotifications(MY_ADDR, { limit: 100, offset: 0 })
|
||||
|
||||
assert.equal(result.notifications.length, 1)
|
||||
const n = result.notifications[0]
|
||||
assert.equal(n.type, 'reply')
|
||||
assert.equal(n.blockHeight, 0)
|
||||
assert.equal(n.seen, 0)
|
||||
assert.equal(result.total, 1)
|
||||
assert.equal(result.notifications[0].addr, FOLLOWER)
|
||||
})
|
||||
|
||||
it('should use the child post blockHeight when the child record lacks one', async () => {
|
||||
const myPostTxid = 'a'.repeat(64)
|
||||
const replyTxid = 'b'.repeat(64)
|
||||
it('should skip a missing like record', async () => {
|
||||
const postsDb = makeDb([['post-recent', { addr: VIEWER, text: 'hi' }]])
|
||||
const addrPostHeightsDb = makeDb([addrPostHeight(VIEWER, 690000, 'post-recent')])
|
||||
const postLikesDb = makeDb([postLike('post-recent', 'like-missing')])
|
||||
|
||||
postChildrenDb.iterator.returns(makeIterator([
|
||||
[`${myPostTxid}:${replyTxid}`, { parentTxid: myPostTxid, childTxid: replyTxid }]
|
||||
]))
|
||||
likesDb.iterator.returns(makeIterator([]))
|
||||
followsDb.iterator.returns(makeIterator([]))
|
||||
postsDb.get.withArgs(myPostTxid).resolves({ addr: MY_ADDR, text: 'hello' })
|
||||
postsDb.get.withArgs(replyTxid).resolves({ addr: THEIR_ADDR, text: 'reply', blockHeight: 500, seen: 7 })
|
||||
|
||||
const result = await uut.listNotifications(MY_ADDR, { limit: 100, offset: 0 })
|
||||
|
||||
assert.equal(result.notifications.length, 1)
|
||||
const n = result.notifications[0]
|
||||
assert.equal(n.blockHeight, 500)
|
||||
assert.equal(n.seen, 7)
|
||||
})
|
||||
|
||||
it('should break sort ties by seen descending when blockHeight is equal', async () => {
|
||||
const myPostTxid = 'a'.repeat(64)
|
||||
|
||||
postChildrenDb.iterator.returns(makeIterator([]))
|
||||
followsDb.iterator.returns(makeIterator([]))
|
||||
likesDb.iterator.returns(makeIterator([
|
||||
['b1'.repeat(32), { addr: THEIR_ADDR, postTxid: myPostTxid, blockHeight: 300, seen: 100 }],
|
||||
['b2'.repeat(32), { addr: 'bitcoincash:qother2', postTxid: myPostTxid, blockHeight: 300, seen: 200 }]
|
||||
]))
|
||||
postsDb.get.withArgs(myPostTxid).resolves({ addr: MY_ADDR, text: 'hello' })
|
||||
|
||||
const result = await uut.listNotifications(MY_ADDR, { limit: 100, offset: 0 })
|
||||
|
||||
assert.equal(result.notifications.length, 2)
|
||||
assert.equal(result.notifications[0].seen, 200)
|
||||
assert.equal(result.notifications[1].seen, 100)
|
||||
})
|
||||
|
||||
it('should skip a like whose post is missing', async () => {
|
||||
const myPostTxid = 'a'.repeat(64)
|
||||
const likeTxid = 'b'.repeat(64)
|
||||
const missingPost = new Error('not found')
|
||||
missingPost.notFound = true
|
||||
|
||||
postChildrenDb.iterator.returns(makeIterator([]))
|
||||
followsDb.iterator.returns(makeIterator([]))
|
||||
likesDb.iterator.returns(makeIterator([
|
||||
[likeTxid, { addr: THEIR_ADDR, postTxid: myPostTxid, blockHeight: 300 }]
|
||||
]))
|
||||
postsDb.get.withArgs(myPostTxid).rejects(missingPost)
|
||||
|
||||
const result = await uut.listNotifications(MY_ADDR, { limit: 100, offset: 0 })
|
||||
const uut = buildQuery({ postsDb, addrPostHeightsDb, postLikesDb })
|
||||
const result = await uut.listNotifications(VIEWER, { limit: 100, offset: 0 })
|
||||
|
||||
assert.equal(result.total, 0)
|
||||
})
|
||||
|
||||
it('should skip a null like record without throwing', async () => {
|
||||
postChildrenDb.iterator.returns(makeIterator([]))
|
||||
followsDb.iterator.returns(makeIterator([]))
|
||||
likesDb.iterator.returns(makeIterator([
|
||||
[null, null]
|
||||
]))
|
||||
|
||||
const result = await uut.listNotifications(MY_ADDR, { limit: 100, offset: 0 })
|
||||
|
||||
assert.equal(result.total, 0)
|
||||
})
|
||||
|
||||
it('should skip a reply child record missing parentTxid or childTxid', async () => {
|
||||
postChildrenDb.iterator.returns(makeIterator([
|
||||
['missing:child', { parentTxid: undefined, childTxid: 'b'.repeat(64), blockHeight: 100 }]
|
||||
]))
|
||||
likesDb.iterator.returns(makeIterator([]))
|
||||
followsDb.iterator.returns(makeIterator([]))
|
||||
|
||||
const result = await uut.listNotifications(MY_ADDR, { limit: 100, offset: 0 })
|
||||
it('should default notificationBlockWindow to 25000', async () => {
|
||||
const addrPostHeightsDb = makeDb([addrPostHeight(VIEWER, 1, 'post-old')])
|
||||
const uut = buildQuery({ addrPostHeightsDb, statusDb: makeDb([['status', { chainBlockHeight: 700000 }]]) })
|
||||
|
||||
// cutoff 675000: a post at height 1 is out of window.
|
||||
const result = await uut.listNotifications(VIEWER, { limit: 100, offset: 0 })
|
||||
assert.equal(result.total, 0)
|
||||
assert.equal(uut.notificationBlockWindow, 25000)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { assert } from 'chai'
|
||||
import { backfillFolloweeIndex, followeeHeightKey } from '../../../src/lib/backfill-followee-index.js'
|
||||
import { FakeDb } from '../../support/level-double.js'
|
||||
|
||||
describe('#backfillFolloweeIndex', () => {
|
||||
// followeePkHash is a 20-byte hash160 hex; cash addresses are the followers.
|
||||
const VIEWER_HASH = 'a1'.repeat(20)
|
||||
const OTHER_HASH = 'b2'.repeat(20)
|
||||
const FOLLOWER_A = 'bitcoincash:qq3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygrg4dtdzf'
|
||||
const FOLLOWER_B = 'bitcoincash:qqenxvenxvenxvenxvenxvenxvenxvenxvn254yg3p'
|
||||
const FOLLOWER_C = 'bitcoincash:qpzyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs7fn3s6pt'
|
||||
|
||||
function followsDb () {
|
||||
return new FakeDb([
|
||||
[`${FOLLOWER_A}:${VIEWER_HASH}`, {
|
||||
followerAddr: FOLLOWER_A,
|
||||
followeePkHash: VIEWER_HASH,
|
||||
unfollow: false,
|
||||
txid: 'follow-a',
|
||||
seen: 1,
|
||||
blockHeight: 690400
|
||||
}],
|
||||
[`${FOLLOWER_B}:${VIEWER_HASH}`, {
|
||||
followerAddr: FOLLOWER_B,
|
||||
followeePkHash: VIEWER_HASH,
|
||||
unfollow: true,
|
||||
txid: 'unfollow-b',
|
||||
seen: 2,
|
||||
blockHeight: 690600
|
||||
}],
|
||||
[`${FOLLOWER_C}:${OTHER_HASH}`, {
|
||||
followerAddr: FOLLOWER_C,
|
||||
followeePkHash: OTHER_HASH,
|
||||
unfollow: false,
|
||||
txid: 'follow-c',
|
||||
seen: 3,
|
||||
blockHeight: 690200
|
||||
}]
|
||||
])
|
||||
}
|
||||
|
||||
it('should index every follow record at its height, preserving unfollow', async () => {
|
||||
const db = followsDb()
|
||||
const followeeHeightsDb = new FakeDb()
|
||||
|
||||
const result = await backfillFolloweeIndex({ followsDb: db, followeeHeightsDb })
|
||||
|
||||
assert.equal(result.follows, 3)
|
||||
assert.deepEqual(followeeHeightsDb.store.get(followeeHeightKey(VIEWER_HASH, 690400, FOLLOWER_A)), {
|
||||
followerAddr: FOLLOWER_A,
|
||||
followeePkHash: VIEWER_HASH,
|
||||
unfollow: false,
|
||||
txid: 'follow-a',
|
||||
seen: 1,
|
||||
blockHeight: 690400
|
||||
})
|
||||
assert.equal(followeeHeightsDb.store.get(followeeHeightKey(VIEWER_HASH, 690600, FOLLOWER_B)).unfollow, true)
|
||||
assert.equal(followeeHeightsDb.store.get(followeeHeightKey(OTHER_HASH, 690200, FOLLOWER_C)).unfollow, false)
|
||||
})
|
||||
|
||||
it('should be idempotent across repeated runs', async () => {
|
||||
const db = followsDb()
|
||||
const followeeHeightsDb = new FakeDb()
|
||||
|
||||
await backfillFolloweeIndex({ followsDb: db, followeeHeightsDb })
|
||||
const sizeAfterFirst = followeeHeightsDb.store.size
|
||||
await backfillFolloweeIndex({ followsDb: db, followeeHeightsDb })
|
||||
|
||||
assert.equal(followeeHeightsDb.store.size, sizeAfterFirst)
|
||||
assert.equal(sizeAfterFirst, 3)
|
||||
})
|
||||
|
||||
it('should leave the follows store unchanged', async () => {
|
||||
const db = followsDb()
|
||||
const before = new Map(db.store)
|
||||
const followeeHeightsDb = new FakeDb()
|
||||
|
||||
await backfillFolloweeIndex({ followsDb: db, followeeHeightsDb })
|
||||
|
||||
assert.deepEqual(db.store, before)
|
||||
})
|
||||
|
||||
it('should recover the follower and followee from the key when fields are missing', async () => {
|
||||
const db = new FakeDb([
|
||||
[`${FOLLOWER_A}:${VIEWER_HASH}`, { unfollow: false, blockHeight: 690400 }]
|
||||
])
|
||||
const followeeHeightsDb = new FakeDb()
|
||||
|
||||
await backfillFolloweeIndex({ followsDb: db, followeeHeightsDb })
|
||||
|
||||
const record = followeeHeightsDb.store.get(followeeHeightKey(VIEWER_HASH, 690400, FOLLOWER_A))
|
||||
assert.equal(record.followerAddr, FOLLOWER_A)
|
||||
assert.equal(record.followeePkHash, VIEWER_HASH)
|
||||
assert.equal(record.unfollow, false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
Utility: build the followeeHeights notification index for an existing
|
||||
psf-memo-db.
|
||||
|
||||
New deployments write followeeHeights while indexing live follow actions, but
|
||||
existing databases that were populated before the notifications query
|
||||
performance feature need a one-time backfill.
|
||||
|
||||
Run from the psf-memo-db repo root on the host that owns the LevelDB files:
|
||||
|
||||
node util/follow/backfill-followee-index.js
|
||||
|
||||
The script is idempotent: re-running it produces the same index. Progress and
|
||||
a summary are printed to stderr.
|
||||
|
||||
WARNING:
|
||||
- This script opens the LevelDB files directly. psf-memo-db must NOT be
|
||||
running, or another process must not hold the database locks.
|
||||
- Make a backup of leveldb/current before running on a production server:
|
||||
cp -r leveldb/current leveldb/current-pre-followee-index-backup
|
||||
*/
|
||||
|
||||
import level from 'level'
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import * as url from 'url'
|
||||
import { backfillFolloweeIndex } from '../../src/lib/backfill-followee-index.js'
|
||||
|
||||
const __dirname = url.fileURLToPath(new URL('.', import.meta.url))
|
||||
|
||||
const DATA_DIR = process.env.PSF_MEMO_DB_DATA_DIR
|
||||
? path.resolve(process.env.PSF_MEMO_DB_DATA_DIR)
|
||||
: path.resolve(__dirname, '../../leveldb/current')
|
||||
|
||||
function requiredStorePath (dir, name) {
|
||||
const storePath = path.join(dir, name)
|
||||
if (!fs.existsSync(storePath)) {
|
||||
throw new Error(`Required LevelDB store not found: ${storePath}. Set PSF_MEMO_DB_DATA_DIR to the directory containing the follows store.`)
|
||||
}
|
||||
return storePath
|
||||
}
|
||||
|
||||
async function main () {
|
||||
console.error(`Using LevelDB data directory: ${DATA_DIR}`)
|
||||
|
||||
const followsPath = requiredStorePath(DATA_DIR, 'follows')
|
||||
const followeeHeightsPath = path.join(DATA_DIR, 'followeeHeights')
|
||||
|
||||
console.error('Opening LevelDB stores...')
|
||||
const followsDb = level(followsPath, { valueEncoding: 'json' })
|
||||
const followeeHeightsDb = level(followeeHeightsPath, { valueEncoding: 'json', createIfMissing: true })
|
||||
|
||||
try {
|
||||
console.error('Backfilling followeeHeights from follows...')
|
||||
const summary = await backfillFolloweeIndex({ followsDb, followeeHeightsDb })
|
||||
|
||||
console.error('\nFollowee index backfill complete.')
|
||||
console.error(` follows indexed: ${summary.follows}`)
|
||||
} catch (err) {
|
||||
console.error('\nFollowee index backfill failed:', err.message)
|
||||
process.exitCode = 1
|
||||
} finally {
|
||||
await followeeHeightsDb.close().catch(() => {})
|
||||
await followsDb.close().catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
@@ -14,6 +14,7 @@ import { handleCreatePoll } from '../../src/use-cases/action-types/poll-create.j
|
||||
import { handleAddPollOption } from '../../src/use-cases/action-types/poll-option.js'
|
||||
import { handlePollVote } from '../../src/use-cases/action-types/poll-vote.js'
|
||||
import { handleMute } from '../../src/use-cases/action-types/mute.js'
|
||||
import { handleFollow } from '../../src/use-cases/action-types/follow.js'
|
||||
import { handleTopicMessage } from '../../src/use-cases/action-types/topic-message.js'
|
||||
import { handleTopicFollow } from '../../src/use-cases/action-types/topic-follow.js'
|
||||
import { topicRecencyKey } from '../../src/use-cases/action-types/helpers.js'
|
||||
@@ -66,6 +67,13 @@ function deriveTxid (symbolic) {
|
||||
return crypto.createHash('sha256').update(symbolic).digest().toString('hex')
|
||||
}
|
||||
|
||||
// Derive a deterministic 20-byte pkHash hex for a symbolic test address. The
|
||||
// follow action payload carries a pkHash, not an address, so the acceptance
|
||||
// step and its assertion must derive the same value from the address.
|
||||
function derivePkHash (addr) {
|
||||
return crypto.createHash('sha256').update(addr).digest().slice(0, 20).toString('hex')
|
||||
}
|
||||
|
||||
function resolveTxid (value, example, world) {
|
||||
const resolved = resolveParam(value, example)
|
||||
if (!world.txidMap) world.txidMap = new Map()
|
||||
@@ -88,6 +96,8 @@ async function createWorld () {
|
||||
const pollOptionDb = makeInMemoryDb()
|
||||
const pollVoteDb = makeInMemoryDb()
|
||||
const muteDb = makeInMemoryDb()
|
||||
const followDb = makeInMemoryDb()
|
||||
const followeeHeightsDb = makeInMemoryDb()
|
||||
const roomDb = makeInMemoryDb()
|
||||
const topicSummaryDb = makeInMemoryDb()
|
||||
const topicRecencyDb = makeInMemoryDb()
|
||||
@@ -104,6 +114,8 @@ async function createWorld () {
|
||||
pollOptionDb,
|
||||
pollVoteDb,
|
||||
muteDb,
|
||||
followDb,
|
||||
followeeHeightDb: followeeHeightsDb,
|
||||
roomDb,
|
||||
topicSummaryDb,
|
||||
topicRecencyDb,
|
||||
@@ -130,6 +142,8 @@ async function createWorld () {
|
||||
pollOptionsDb: pollOptionDb,
|
||||
pollVotesDb: pollVoteDb,
|
||||
mutesDb: muteDb,
|
||||
followsDb: followDb,
|
||||
followeeHeightsDb,
|
||||
roomsDb: roomDb,
|
||||
topicSummariesDb: topicSummaryDb,
|
||||
topicRecencyDb,
|
||||
@@ -588,6 +602,90 @@ const handlers = [
|
||||
throw new Error(`Expected ${expectedCount} postLikes entry/entries for ${postTxid}/${likeTxid}, got ${matching.length}`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'db instance with follows and followeeHeights stores',
|
||||
pattern: /^a psf-memo-db instance with follows and followeeHeights stores$/,
|
||||
async run () {
|
||||
// World is already created with both stores.
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'process a Memo follow',
|
||||
pattern: /^the indexer processes a Memo follow of (.+) from (.+) at block height (.+)$/,
|
||||
async run (m, example, world) {
|
||||
const followee = resolveParam(m[1], example)
|
||||
const follower = resolveParam(m[2], example)
|
||||
const height = parseInt(resolveParam(m[3], example), 10)
|
||||
const txid = deriveTxid(`follow-${follower}-${followee}-${height}`)
|
||||
|
||||
const prefix = Buffer.from('6d06', 'hex')
|
||||
const hashBuf = Buffer.from(derivePkHash(followee), 'hex')
|
||||
const ctx = {
|
||||
adapters: world.adapters,
|
||||
txid,
|
||||
signerAddr: follower,
|
||||
seen: Date.now(),
|
||||
blockHeight: height,
|
||||
decoded: { action: 'follow', prefix, pushDatas: [prefix, hashBuf] }
|
||||
}
|
||||
world.lastFollow = { followee, follower, height, ctx }
|
||||
|
||||
await handleFollow(ctx)
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'process a Memo unfollow',
|
||||
pattern: /^the indexer processes a Memo unfollow of (.+) from (.+) at block height (.+)$/,
|
||||
async run (m, example, world) {
|
||||
const followee = resolveParam(m[1], example)
|
||||
const follower = resolveParam(m[2], example)
|
||||
const height = parseInt(resolveParam(m[3], example), 10)
|
||||
const txid = deriveTxid(`unfollow-${follower}-${followee}-${height}`)
|
||||
|
||||
const prefix = Buffer.from('6d07', 'hex')
|
||||
const hashBuf = Buffer.from(derivePkHash(followee), 'hex')
|
||||
const ctx = {
|
||||
adapters: world.adapters,
|
||||
txid,
|
||||
signerAddr: follower,
|
||||
seen: Date.now(),
|
||||
blockHeight: height,
|
||||
decoded: { action: 'unfollow', prefix, pushDatas: [prefix, hashBuf] }
|
||||
}
|
||||
world.lastFollow = { followee, follower, height, ctx }
|
||||
|
||||
await handleFollow(ctx)
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'process the same Memo follow again',
|
||||
pattern: /^the indexer processes the same Memo follow of (.+) from (.+) again$/,
|
||||
async run (m, example, world) {
|
||||
if (!world.lastFollow) {
|
||||
throw new Error('No previous follow to reprocess')
|
||||
}
|
||||
await handleFollow(world.lastFollow.ctx)
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'followeeHeights store contains entry',
|
||||
pattern: /^the followeeHeights store contains (.+) entr(?:y|ies) for followee (.+) from (.+) at block height (.+) marked unfollow (true|false)$/,
|
||||
run (m, example, world) {
|
||||
const expectedCount = parseInt(resolveParam(m[1], example), 10)
|
||||
const followee = resolveParam(m[2], example)
|
||||
const follower = resolveParam(m[3], example)
|
||||
const height = parseInt(resolveParam(m[4], example), 10)
|
||||
const expectedUnfollow = m[5] === 'true'
|
||||
const pkHash = derivePkHash(followee)
|
||||
const key = `${pkHash}:${String(height).padStart(12, '0')}:${follower}`
|
||||
const matching = world.followeeHeightsDb.entries().filter(([k, value]) => {
|
||||
return k === key && value?.unfollow === expectedUnfollow
|
||||
})
|
||||
if (matching.length !== expectedCount) {
|
||||
throw new Error(`Expected ${expectedCount} followeeHeights entry/entries for ${key} unfollow ${expectedUnfollow}, got ${matching.length}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -166,6 +166,37 @@ This document records **why** the Memo indexer stack looks the way it does, incl
|
||||
|
||||
**Recommended e2e smoke:** Index block ≥ 525000 containing a known Memo post from [memo-protocol.md](../../memo-protocol.md) explorer links; verify `GET /level/post/:txid`.
|
||||
|
||||
## 16. Bounded notification reads (per-object scans + block window)
|
||||
|
||||
**Decision:** `GET /posts/notifications/:addr` no longer full-scans the global
|
||||
`likes`, `postChildren`, and `follows` stores. Instead it bounds the work to the
|
||||
viewer's activity inside a configurable block window (`NOTIFICATION_BLOCK_WINDOW`,
|
||||
default 25000; cutoff = `status.chainBlockHeight - window`):
|
||||
|
||||
- the viewer's posts come from the `addrPostHeights` index, range-limited to the
|
||||
window;
|
||||
- likes and replies are prefix-scanned from `postLikes` and `postChildren` for
|
||||
those posts only;
|
||||
- follows come from a new followee-keyed, height-ordered `followeeHeights` index
|
||||
maintained by the indexer, range-limited to the window.
|
||||
|
||||
**Reason:** The original request cost `O(all likes + all replies + all follows)`
|
||||
and loaded a post for every candidate, so the `/notifications` page blocked on a
|
||||
scan proportional to the whole database rather than the viewer's activity.
|
||||
|
||||
**Tradeoff:** A notification is drawn from the viewer's content inside the
|
||||
window, so an interaction with a post older than the window is not returned even
|
||||
when the interaction itself is recent. This is acceptable for a social inbox:
|
||||
the window bounds staleness and keeps the read proportional to recent activity.
|
||||
The `followeeHeights` index is an append-only event log per followee; the read
|
||||
side takes the newest entry per follower and ignores unfollows, so an unfollow
|
||||
removes the notification without deleting event history. The `follows` store
|
||||
remains the source of truth for the current follow graph.
|
||||
|
||||
**Backfill:** Databases populated before this feature need a one-time
|
||||
`util/follow/backfill-followee-index.js` run, which projects every `follows`
|
||||
record into a `followeeHeights` entry at its block height and is idempotent.
|
||||
|
||||
## Decision log (quick reference)
|
||||
|
||||
| Topic | Choice |
|
||||
|
||||
@@ -48,6 +48,9 @@ leveldb/current/{name} // valueEncoding: 'json'
|
||||
| `profiles` | 64 MB | Profile text |
|
||||
| `profilePics` | 64 MB | Avatar URLs |
|
||||
| `follows` | 64 MB | Follow graph edges |
|
||||
| `addrPostHeights` | 64 MB | Posts by address ordered by height |
|
||||
| `postLikes` | 64 MB | Like txids grouped by liked post |
|
||||
| `followeeHeights` | 64 MB | Follow/unfollow events keyed by followee and height |
|
||||
| `rooms` | 64 MB | Topic posts and follows |
|
||||
| `processErrors` | 64 MB | Skipped / invalid txs |
|
||||
| `ptxs` | 64 MB | Processed tx markers |
|
||||
@@ -77,6 +80,7 @@ All routes are under `/level` with a consistent CRUD pattern generated from `ENT
|
||||
| `profile` | `addr` | `profileData` | cash address |
|
||||
| `profilepic` | `addr` | `profilePicData` | cash address |
|
||||
| `follow` | `key` | `followData` | `follower:followeePkHash` |
|
||||
| `followeeheight` | `key` | `followeeHeightData` | `followeePkHash:paddedHeight:followerAddr` |
|
||||
| `room` | `key` | `roomData` | composite |
|
||||
| `processerror` | `txid` | `errorData` | txid |
|
||||
| `ptx` | `txid` | `ptxData` | txid |
|
||||
@@ -87,6 +91,7 @@ All routes are under `/level` with a consistent CRUD pattern generated from `ENT
|
||||
|--------|------|----------------|
|
||||
| `GET` | `/profile/recent` | `limit` (default 100, max 100), `offset` (default 0) |
|
||||
| `GET` | `/posts/recent` | `limit` (default 100, max 100), `offset` (default 0) |
|
||||
| `GET` | `/posts/notifications/:addr` | `limit` (default 100, max 100), `offset` (default 0) |
|
||||
|
||||
Returns profiles or posts sorted by **block height** (newest first), using the `blockHeight` field stored on each entity document at indexing time. Tie-breaker: `seen` timestamp descending.
|
||||
|
||||
@@ -130,6 +135,29 @@ Implementation: `profile-query` / `post-query` adapter (LevelDB scan) → `list-
|
||||
|
||||
**Tradeoff:** Full scan of `profiles` on each request; suitable for moderate corpus sizes. A height-indexed store would be needed for very large archives.
|
||||
|
||||
#### Notifications
|
||||
|
||||
`GET /posts/notifications/:addr` returns likes on the viewer's posts, replies to
|
||||
the viewer's posts, and follows of the viewer, newest first. Work is bounded to
|
||||
the viewer's activity inside a configurable block window
|
||||
(`NOTIFICATION_BLOCK_WINDOW`, default `25000`; cutoff =
|
||||
`status.chainBlockHeight - window`):
|
||||
|
||||
- the viewer's posts are read from `addrPostHeights`, range-limited to the
|
||||
window;
|
||||
- likes and replies are prefix-scanned from `postLikes` and `postChildren` for
|
||||
those posts only (the global `likes` and `postChildren` stores are never
|
||||
iterated);
|
||||
- follows are read from `followeeHeights`, range-limited to the window, keeping
|
||||
the newest entry per follower and ignoring unfollows (the `follows` store is
|
||||
never iterated).
|
||||
|
||||
`pagination.total` counts only in-window notifications. Because a notification
|
||||
is drawn from the viewer's content inside the window, an interaction with a
|
||||
post older than the window is not returned even when the interaction itself is
|
||||
recent. The indexer writes `followeeHeights` on every follow/unfollow; existing
|
||||
databases need the one-time `util/follow/backfill-followee-index.js` backfill.
|
||||
|
||||
### Status (special case)
|
||||
|
||||
Matches SLP status semantics:
|
||||
@@ -187,6 +215,7 @@ Common fields on indexed documents:
|
||||
| profilePic | addr | `url`, `txid`, `seen`, `addr`, `blockHeight` |
|
||||
| like | txid | `addr`, `postTxid`, `seen`, `tip`, `blockHeight` |
|
||||
| follow | composite key | `followerAddr`, `followeePkHash`, `unfollow`, `txid`, `seen`, `blockHeight` |
|
||||
| followeeHeights | `followeePkHash:paddedHeight:followerAddr` | `followerAddr`, `followeePkHash`, `unfollow`, `txid`, `seen`, `blockHeight` |
|
||||
| postParent / postChild | txid / `parentTxid:childTxid` | `parentTxid`, `childTxid`, `blockHeight` |
|
||||
| room | composite key | `room`, `txid`, `seen`, `type`, `blockHeight` (+ `addr` for follows) |
|
||||
| processError | txid | `error`, `ts`, `blockHeight` |
|
||||
@@ -203,6 +232,7 @@ Common fields on indexed documents:
|
||||
| `SVC_ENV` | `development` | Config profile |
|
||||
| `BACKUP_QTY` | `3` | Retained zip backups |
|
||||
| `EXIT_ON_MISSING_BACKUP` | `false` | Fail restore if zip missing |
|
||||
| `NOTIFICATION_BLOCK_WINDOW` | `25000` | Blocks before the chain tip that still count as a notification |
|
||||
|
||||
## Testing
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ class Adapters {
|
||||
this.profileDb = createEntityDb('profile', 'addr', 'profileData')
|
||||
this.profilePicDb = createEntityDb('profilepic', 'addr', 'profilePicData')
|
||||
this.followDb = createEntityDb('follow', 'key', 'followData')
|
||||
this.followeeHeightDb = createEntityDb('followeeheight', 'key', 'followeeHeightData')
|
||||
this.muteDb = createEntityDb('mute', 'key', 'muteData')
|
||||
this.roomDb = createEntityDb('room', 'key', 'roomData')
|
||||
this.topicSummaryDb = createEntityDb('topicsummary', 'key', 'topicSummaryData')
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { logProcessError } from './helpers.js'
|
||||
import { logProcessError, followeeHeightKey } from './helpers.js'
|
||||
import { PK_HASH_LENGTH, PREFIX_UNFOLLOW } from '../../lib/memo-codes.js'
|
||||
|
||||
export async function handleFollow (ctx) {
|
||||
@@ -19,12 +19,20 @@ export async function handleFollow (ctx) {
|
||||
const followeePkHash = pushDatas[1].toString('hex')
|
||||
|
||||
const key = `${signerAddr}:${followeePkHash}`
|
||||
await adapters.followDb.create(key, {
|
||||
const record = {
|
||||
followerAddr: signerAddr,
|
||||
followeePkHash,
|
||||
unfollow,
|
||||
txid,
|
||||
seen,
|
||||
blockHeight
|
||||
})
|
||||
}
|
||||
await adapters.followDb.create(key, record)
|
||||
|
||||
// Mirror the event into the followeeHeights notification index so the read
|
||||
// side can find the viewer's follows without scanning the follows store.
|
||||
await adapters.followeeHeightDb.create(
|
||||
followeeHeightKey(followeePkHash, blockHeight, signerAddr),
|
||||
record
|
||||
)
|
||||
}
|
||||
|
||||
@@ -104,6 +104,14 @@ export function postChildKey (parentTxid, childTxid) {
|
||||
return `${parentTxid}:${childTxid}`
|
||||
}
|
||||
|
||||
// followeeHeights is a followee-keyed, height-ordered notification read index:
|
||||
// `${followeePkHash}:${paddedBlockHeight}:${followerAddr}`. The read side
|
||||
// range-scans one followee's suffix to find its newest follow per follower.
|
||||
export function followeeHeightKey (followeePkHash, blockHeight, followerAddr) {
|
||||
const padded = String(blockHeight ?? 0).padStart(12, '0')
|
||||
return `${followeePkHash}:${padded}:${followerAddr}`
|
||||
}
|
||||
|
||||
// mutate4javascript-manifest-begin
|
||||
// {"version":1,"tested_at":"2026-09-17T16:25:37.308Z","module_hash":"00fa2f64726246ec4e403d93b7cabaf0fb5e2753614533c0b3651b23d6147749","functions":[{"id":"func/logProcessError","name":"logProcessError","line":7,"end_line":13,"hash":"78f39be271917cad97072ed5e2d1f531a30a37bc9c10169516e0457951c438f4"},{"id":"func/utf8FromPush","name":"utf8FromPush","line":15,"end_line":17,"hash":"356fc665a6389e392ec83522210dd95b2af6be341d00dc5edc0bbabfbd511d9e"},{"id":"func/stripLeadingEmptyPushes","name":"stripLeadingEmptyPushes","line":22,"end_line":28,"hash":"be7c401e07e4ef3670428afe7211860038c69da17abcd03508ea5cb640b46163"},{"id":"func/normalizeTwoPushMemoDatas","name":"normalizeTwoPushMemoDatas","line":34,"end_line":46,"hash":"61d050380c2cef7eed8870a66e347f1d01c56246d733d95d692b436cfb4a20ed"},{"id":"func/txHashFromPush","name":"txHashFromPush","line":48,"end_line":55,"hash":"704934308365cf0fbbb72aa437faf5db20a69bd57a16ba37b19c905ddc922ea9"},{"id":"func/followKey","name":"followKey","line":57,"end_line":59,"hash":"e1c5cfdd78afd3945e18e4af06e443efb306c27b07e3b3534c918ef000da9696"},{"id":"func/roomKey","name":"roomKey","line":61,"end_line":63,"hash":"d4f146c7a938bb6b30b13f071609a3ac4e96a26f116e321b804c16114884cd15"},{"id":"func/topicRecencyKey","name":"topicRecencyKey","line":68,"end_line":71,"hash":"ce7146b8dfca80309058c1512818fe2edc9f1b3658cbe1e06aabf78f65136b90"},{"id":"func/isNotFound","name":"isNotFound","line":75,"end_line":77,"hash":"662978032673c9bac6d64c519899ebfed72e477ea39b9fde19039ffa01de183d"},{"id":"func/getIfPresent","name":"getIfPresent","line":80,"end_line":87,"hash":"b012b3ef1d48f45a65fcbd4ff9574443cc98d2c2f7d12a504af74fc3e0d3f5af"},{"id":"func/postHeightKey","name":"postHeightKey","line":89,"end_line":92,"hash":"25bdb7995aeb2823d0f808812251c3fd8c163089ea32b4f39ddf55520e03cbb1"},{"id":"func/addrPostHeightKey","name":"addrPostHeightKey","line":94,"end_line":97,"hash":"b9b6b08e4c5892905dc6577ad22dd48c13598ca205b1b8c13d7cc9622962879f"},{"id":"func/postLikeKey","name":"postLikeKey","line":99,"end_line":101,"hash":"627ce4b3ca6a9d28fbaa50bc1f51973509e7d8b52eda9208343dac1d936d7f51"},{"id":"func/postChildKey","name":"postChildKey","line":103,"end_line":105,"hash":"4d30329db33e34b0461bd1ba70b078fbef285a338f17f7e4d1166ca5bd107893"}]}
|
||||
// mutate4javascript-manifest-end
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { assert } from 'chai'
|
||||
import sinon from 'sinon'
|
||||
import { handleFollow } from '../../../../src/use-cases/action-types/follow.js'
|
||||
import { PREFIX_UNFOLLOW } from '../../../../src/lib/memo-codes.js'
|
||||
|
||||
describe('#handleFollow', () => {
|
||||
let adapters
|
||||
let followCreate
|
||||
let followeeHeightCreate
|
||||
let processErrorCreate
|
||||
|
||||
const FOLLOWER = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
|
||||
const FOLLOWER2 = 'bitcoincash:qpm2qsznhks23z7629mms6s4cwef74vcwvy22gdx6a'
|
||||
const FOLLOWEE_HASH = 'cb481232299cd5743151ac4b2d63ae198e7bb0a9'
|
||||
|
||||
function baseCtx (overrides = {}) {
|
||||
const hashBuf = Buffer.from(FOLLOWEE_HASH, 'hex')
|
||||
return {
|
||||
adapters,
|
||||
txid: 'follow-tx',
|
||||
signerAddr: FOLLOWER,
|
||||
seen: 1000,
|
||||
blockHeight: 690100,
|
||||
decoded: {
|
||||
action: 'follow',
|
||||
prefix: Buffer.from('6d06', 'hex'),
|
||||
pushDatas: [Buffer.from('6d06', 'hex'), hashBuf]
|
||||
},
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
followCreate = sinon.stub().resolves({ success: true })
|
||||
followeeHeightCreate = sinon.stub().resolves({ success: true })
|
||||
processErrorCreate = sinon.stub().resolves({ success: true })
|
||||
adapters = {
|
||||
followDb: { create: followCreate },
|
||||
followeeHeightDb: { create: followeeHeightCreate },
|
||||
processErrorDb: { create: processErrorCreate }
|
||||
}
|
||||
})
|
||||
|
||||
it('should save the follows record and mirror it into followeeHeights', async () => {
|
||||
await handleFollow(baseCtx())
|
||||
|
||||
assert.equal(followCreate.callCount, 1)
|
||||
assert.equal(followCreate.firstCall.args[0], `${FOLLOWER}:${FOLLOWEE_HASH}`)
|
||||
assert.equal(followCreate.firstCall.args[1].followerAddr, FOLLOWER)
|
||||
assert.equal(followCreate.firstCall.args[1].followeePkHash, FOLLOWEE_HASH)
|
||||
assert.equal(followCreate.firstCall.args[1].unfollow, false)
|
||||
|
||||
assert.equal(followeeHeightCreate.callCount, 1)
|
||||
assert.equal(
|
||||
followeeHeightCreate.firstCall.args[0],
|
||||
`${FOLLOWEE_HASH}:000000690100:${FOLLOWER}`
|
||||
)
|
||||
const indexed = followeeHeightCreate.firstCall.args[1]
|
||||
assert.equal(indexed.followerAddr, FOLLOWER)
|
||||
assert.equal(indexed.followeePkHash, FOLLOWEE_HASH)
|
||||
assert.equal(indexed.unfollow, false)
|
||||
assert.equal(indexed.txid, 'follow-tx')
|
||||
assert.equal(indexed.blockHeight, 690100)
|
||||
})
|
||||
|
||||
it('should mark an unfollow entry as unfollow true', async () => {
|
||||
await handleFollow(baseCtx({
|
||||
decoded: {
|
||||
action: 'unfollow',
|
||||
prefix: PREFIX_UNFOLLOW,
|
||||
pushDatas: [PREFIX_UNFOLLOW, Buffer.from(FOLLOWEE_HASH, 'hex')]
|
||||
}
|
||||
}))
|
||||
|
||||
assert.equal(followCreate.firstCall.args[1].unfollow, true)
|
||||
assert.equal(followeeHeightCreate.callCount, 1)
|
||||
assert.equal(followeeHeightCreate.firstCall.args[1].unfollow, true)
|
||||
})
|
||||
|
||||
it('should use the same followeeHeights key when a follow is reprocessed', async () => {
|
||||
await handleFollow(baseCtx())
|
||||
await handleFollow(baseCtx())
|
||||
|
||||
assert.equal(followeeHeightCreate.callCount, 2)
|
||||
assert.equal(
|
||||
followeeHeightCreate.firstCall.args[0],
|
||||
followeeHeightCreate.secondCall.args[0]
|
||||
)
|
||||
})
|
||||
|
||||
it('should scope the followeeHeights key to the follower and height', async () => {
|
||||
await handleFollow(baseCtx({ signerAddr: FOLLOWER2, blockHeight: 690200 }))
|
||||
|
||||
assert.equal(
|
||||
followeeHeightCreate.firstCall.args[0],
|
||||
`${FOLLOWEE_HASH}:000000690200:${FOLLOWER2}`
|
||||
)
|
||||
})
|
||||
|
||||
it('should log a process error when push data count is not 2', async () => {
|
||||
await handleFollow(baseCtx({
|
||||
decoded: { pushDatas: [Buffer.from('6d06', 'hex'), Buffer.alloc(20, 1), Buffer.alloc(20, 2)] }
|
||||
}))
|
||||
|
||||
assert.equal(processErrorCreate.callCount, 1)
|
||||
assert.include(processErrorCreate.firstCall.args[1].error, 'invalid follow push data count')
|
||||
assert.equal(followCreate.callCount, 0)
|
||||
assert.equal(followeeHeightCreate.callCount, 0)
|
||||
})
|
||||
|
||||
it('should log a process error when the followee hash has the wrong size', async () => {
|
||||
await handleFollow(baseCtx({
|
||||
decoded: { pushDatas: [Buffer.from('6d06', 'hex'), Buffer.alloc(32, 1)] }
|
||||
}))
|
||||
|
||||
assert.equal(processErrorCreate.callCount, 1)
|
||||
assert.include(processErrorCreate.firstCall.args[1].error, 'follow pk hash wrong size')
|
||||
assert.equal(followCreate.callCount, 0)
|
||||
assert.equal(followeeHeightCreate.callCount, 0)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user