mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo.git
synced 2026-09-21 16:52:01 -07:00
Implement topic metadata columns
Add lastSeen and followerCount to topicSummaries across the indexer, DB, and client. The indexer now tracks the newest post seen time, updates follower counts idempotently on follow/unfollow, and preserves post metadata. The DB exposes lastSeen and followerCount from GET /topics and rebuilds both from the rooms store in the backfill. The client renders the four-column topics page with relative-time labels. By coder.
This commit is contained in:
@@ -217,6 +217,7 @@ function makeMemoDb () {
|
||||
const topicPosts = {}
|
||||
const topicCounts = new Map()
|
||||
const topicFollow = new Map()
|
||||
const topicMetadata = new Map()
|
||||
|
||||
return {
|
||||
posts,
|
||||
@@ -228,6 +229,7 @@ function makeMemoDb () {
|
||||
topics,
|
||||
topicPosts,
|
||||
topicCounts,
|
||||
topicMetadata,
|
||||
addPost (post) {
|
||||
posts.push(post)
|
||||
},
|
||||
@@ -274,6 +276,7 @@ function makeMemoDb () {
|
||||
},
|
||||
addTopic (room, postCount) {
|
||||
topicCounts.set(room, postCount)
|
||||
topicMetadata.set(room, { followerCount: 0, lastSeen: 0 })
|
||||
topicPosts[room] = []
|
||||
for (let i = 0; i < postCount; i++) {
|
||||
const txid = `${room}-post-${i + 1}`.padEnd(64, '0')
|
||||
@@ -285,6 +288,16 @@ function makeMemoDb () {
|
||||
})
|
||||
}
|
||||
},
|
||||
setTopicFollowerCount (room, followerCount) {
|
||||
const meta = topicMetadata.get(room) || {}
|
||||
meta.followerCount = followerCount
|
||||
topicMetadata.set(room, meta)
|
||||
},
|
||||
setTopicLastSeen (room, lastSeen) {
|
||||
const meta = topicMetadata.get(room) || {}
|
||||
meta.lastSeen = lastSeen
|
||||
topicMetadata.set(room, meta)
|
||||
},
|
||||
addTopicPost (room, post) {
|
||||
if (!topicPosts[room]) topicPosts[room] = []
|
||||
topicPosts[room].push(post)
|
||||
@@ -364,7 +377,13 @@ function makeMemoDb () {
|
||||
async getTopics ({ limit = 50, offset = 0 } = {}) {
|
||||
const list = []
|
||||
for (const [room, postCount] of topicCounts.entries()) {
|
||||
list.push({ room, postCount })
|
||||
const meta = topicMetadata.get(room) || {}
|
||||
list.push({
|
||||
room,
|
||||
postCount,
|
||||
lastSeen: meta.lastSeen ?? 0,
|
||||
followerCount: meta.followerCount ?? 0
|
||||
})
|
||||
}
|
||||
list.sort((a, b) => a.room.localeCompare(b.room))
|
||||
const total = list.length
|
||||
@@ -2101,13 +2120,38 @@ const handlers = [
|
||||
},
|
||||
{
|
||||
name: 'API serves topic with post count',
|
||||
pattern: /^the psf-memo-db API serves a topic named "([^"]+)" with (\d+) posts?$/,
|
||||
pattern: /^the psf-memo-db API serves a topic named "([^"]+)" with (\S+) posts?$/,
|
||||
run (m, example, world) {
|
||||
const room = m[1]
|
||||
const count = parseInt(m[2], 10)
|
||||
const room = resolveParam(m[1], example)
|
||||
const count = parseInt(resolveParam(m[2], example), 10)
|
||||
world.memoDb.addTopic(room, count)
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'topic has follower count',
|
||||
pattern: /^the topic "([^"]+)" has (\S+) followers?$/,
|
||||
run (m, example, world) {
|
||||
const room = resolveParam(m[1], example)
|
||||
const followerCount = parseInt(resolveParam(m[2], example), 10)
|
||||
world.memoDb.setTopicFollowerCount(room, followerCount)
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'topic was last posted at',
|
||||
pattern: /^the topic "([^"]+)" was last posted at (\S+)$/,
|
||||
run (m, example, world) {
|
||||
const room = resolveParam(m[1], example)
|
||||
const lastSeen = parseInt(resolveParam(m[2], example), 10)
|
||||
world.memoDb.setTopicLastSeen(room, lastSeen)
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'set current time',
|
||||
pattern: /^the current time is (\S+)$/,
|
||||
run (m, example, world) {
|
||||
world.currentTime = parseInt(resolveParam(m[1], example), 10)
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'API serves post in topic with address and text',
|
||||
pattern: /^the psf-memo-db API serves a post with txid (.+) in the topic "([^"]+)" authored by the address (.+) with text (.+)$/,
|
||||
@@ -2196,7 +2240,7 @@ const handlers = [
|
||||
},
|
||||
{
|
||||
name: 'topics page shows topic count',
|
||||
pattern: /^the topics page shows the topic (<topic>) with (<count>) posts$/,
|
||||
pattern: /^the topics page shows the topic "?([^"]+?)"? with (\S+) posts$/,
|
||||
run (m, example, world) {
|
||||
const room = resolveParam(m[1], example)
|
||||
const expected = parseInt(resolveParam(m[2], example), 10)
|
||||
@@ -2209,6 +2253,34 @@ const handlers = [
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'topics page shows topic follower count',
|
||||
pattern: /^the topics page shows the topic "?([^"]+?)"? with (\S+) followers?$/,
|
||||
run (m, example, world) {
|
||||
const room = resolveParam(m[1], example)
|
||||
const expected = parseInt(resolveParam(m[2], example), 10)
|
||||
const topic = world.topicDiscoveryPage.getTopic(room)
|
||||
if (!topic) {
|
||||
throw new Error(`Topic ${room} is not shown on the topics page.`)
|
||||
}
|
||||
if (topic.followerCount !== expected) {
|
||||
throw new Error(`Expected ${room} to have ${expected} followers, got ${topic.followerCount}.`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'topics page shows most recent post label',
|
||||
pattern: /^the topics page shows the most recent post for the topic "([^"]+)" as "(.+)"$/,
|
||||
run (m, example, world) {
|
||||
const room = resolveParam(m[1], example)
|
||||
const expected = resolveParam(m[2], example)
|
||||
const now = world.currentTime ?? Date.now()
|
||||
const actual = world.topicDiscoveryPage.getLastSeenLabel(room, now)
|
||||
if (actual !== expected) {
|
||||
throw new Error(`Expected ${room} most recent post "${expected}", got "${actual}".`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'click topic',
|
||||
pattern: /^I click the topic (<topic>)$/,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useNavigate } from 'react-router-dom'
|
||||
// Local libraries
|
||||
import MemoDb from '../../../services/memo-db'
|
||||
import TopicDiscoveryPage from '../../../services/topic-discovery-page'
|
||||
import { relativeTime } from '../../../services/relative-time'
|
||||
import '../../../App.css'
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
@@ -101,9 +102,12 @@ function Topics (props) {
|
||||
key={topic.room}
|
||||
action
|
||||
onClick={() => handleClick(topic.room)}
|
||||
className='d-flex justify-content-between align-items-center'
|
||||
>
|
||||
#{topic.room}{' '}
|
||||
<span className='text-muted'>({topic.postCount} posts)</span>
|
||||
<span className='topic-name'>#{topic.room}</span>
|
||||
<span className='topic-last-seen text-muted'>{relativeTime(topic.lastSeen, Date.now())}</span>
|
||||
<span className='topic-post-count text-muted'>{topic.postCount} posts</span>
|
||||
<span className='topic-follower-count text-muted'>{topic.followerCount} followers</span>
|
||||
</ListGroup.Item>
|
||||
))}
|
||||
</ListGroup>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
Format the time since a topic's most recent post as a short label.
|
||||
|
||||
Inputs are epoch milliseconds: `lastSeen` is the room's most recent post
|
||||
time (0 when the room has no posts) and `now` is the current time. The
|
||||
labels match the topics page contract:
|
||||
- no posts -> "No posts"
|
||||
- less than an hour -> "Less than an hour ago"
|
||||
- under 24 hours -> "N hours ago" (singular "1 hour ago")
|
||||
- 24 hours or more -> "N days ago" (singular "1 day ago")
|
||||
*/
|
||||
|
||||
const HOUR_MS = 60 * 60 * 1000
|
||||
const DAY_MS = 24 * HOUR_MS
|
||||
|
||||
function relativeTime (lastSeen, now = Date.now()) {
|
||||
if (!lastSeen) return 'No posts'
|
||||
|
||||
const elapsed = now - lastSeen
|
||||
if (elapsed < HOUR_MS) return 'Less than an hour ago'
|
||||
|
||||
if (elapsed < DAY_MS) {
|
||||
const hours = Math.floor(elapsed / HOUR_MS)
|
||||
return hours === 1 ? '1 hour ago' : `${hours} hours ago`
|
||||
}
|
||||
|
||||
const days = Math.floor(elapsed / DAY_MS)
|
||||
return days === 1 ? '1 day ago' : `${days} days ago`
|
||||
}
|
||||
|
||||
module.exports = { relativeTime }
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
|
||||
const PaginatedPage = require('./paginated-page')
|
||||
const { relativeTime } = require('./relative-time')
|
||||
|
||||
const TOPICS_PATH = '/topics'
|
||||
|
||||
@@ -24,6 +25,15 @@ class TopicDiscoveryPage extends PaginatedPage {
|
||||
return this.topics.find((topic) => topic.room === room) || null
|
||||
}
|
||||
|
||||
// Label describing how long ago the room's most recent post was, computed
|
||||
// from the API's lastSeen timestamp. Returns null when the topic is not
|
||||
// loaded. `now` is injectable so the label is deterministic in tests.
|
||||
getLastSeenLabel (room, now = Date.now()) {
|
||||
const topic = this.getTopic(room)
|
||||
if (!topic) return null
|
||||
return relativeTime(topic.lastSeen, now)
|
||||
}
|
||||
|
||||
openTopic (room) {
|
||||
const path = TopicDiscoveryPage.topicFeedPath(room)
|
||||
this.navigate(path)
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
Unit tests for the relative-time formatter used by the topics page.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const { relativeTime } = require('../../src/services/relative-time')
|
||||
|
||||
const NOW = 1800000000000
|
||||
const HOUR = 60 * 60 * 1000
|
||||
const DAY = 24 * HOUR
|
||||
|
||||
test('returns "No posts" when there is no last-seen time', () => {
|
||||
assert.equal(relativeTime(0, NOW), 'No posts')
|
||||
assert.equal(relativeTime(null, NOW), 'No posts')
|
||||
assert.equal(relativeTime(undefined, NOW), 'No posts')
|
||||
})
|
||||
|
||||
test('returns "Less than an hour ago" for under an hour', () => {
|
||||
assert.equal(relativeTime(NOW - 1, NOW), 'Less than an hour ago')
|
||||
assert.equal(relativeTime(NOW - (HOUR - 1), NOW), 'Less than an hour ago')
|
||||
})
|
||||
|
||||
test('returns "1 hour ago" at exactly one hour', () => {
|
||||
assert.equal(relativeTime(NOW - HOUR, NOW), '1 hour ago')
|
||||
})
|
||||
|
||||
test('returns whole hours under a day', () => {
|
||||
assert.equal(relativeTime(NOW - 2 * HOUR, NOW), '2 hours ago')
|
||||
assert.equal(relativeTime(NOW - 5 * HOUR, NOW), '5 hours ago')
|
||||
assert.equal(relativeTime(NOW - 23 * HOUR, NOW), '23 hours ago')
|
||||
})
|
||||
|
||||
test('returns "1 day ago" at exactly one day', () => {
|
||||
assert.equal(relativeTime(NOW - DAY, NOW), '1 day ago')
|
||||
})
|
||||
|
||||
test('returns whole days at or beyond a day', () => {
|
||||
assert.equal(relativeTime(NOW - 2 * DAY, NOW), '2 days ago')
|
||||
assert.equal(relativeTime(NOW - 30 * DAY, NOW), '30 days ago')
|
||||
})
|
||||
|
||||
test('floors partial hours and days', () => {
|
||||
assert.equal(relativeTime(NOW - (5 * HOUR + 59 * 60 * 1000), NOW), '5 hours ago')
|
||||
assert.equal(relativeTime(NOW - (2 * DAY + 23 * HOUR), NOW), '2 days ago')
|
||||
})
|
||||
@@ -110,6 +110,34 @@ test('getTopic returns null when the topic is not loaded', async () => {
|
||||
assert.equal(page.getTopic('bitcoin'), null)
|
||||
})
|
||||
|
||||
test('getLastSeenLabel formats the topic last-seen time', async () => {
|
||||
const page = new TopicDiscoveryPage({
|
||||
memoDb: makeMemoDb({ topics: [{ room: 'bitcoin', postCount: 1, lastSeen: 1799998200000 }] })
|
||||
})
|
||||
|
||||
await page.load()
|
||||
|
||||
assert.equal(page.getLastSeenLabel('bitcoin', 1800000000000), 'Less than an hour ago')
|
||||
})
|
||||
|
||||
test('getLastSeenLabel returns "No posts" for a topic with no posts', async () => {
|
||||
const page = new TopicDiscoveryPage({
|
||||
memoDb: makeMemoDb({ topics: [{ room: 'lone', postCount: 0, lastSeen: 0 }] })
|
||||
})
|
||||
|
||||
await page.load()
|
||||
|
||||
assert.equal(page.getLastSeenLabel('lone', 1800000000000), 'No posts')
|
||||
})
|
||||
|
||||
test('getLastSeenLabel returns null for an unknown topic', async () => {
|
||||
const page = new TopicDiscoveryPage({ memoDb: makeMemoDb({ topics: [] }) })
|
||||
|
||||
await page.load()
|
||||
|
||||
assert.equal(page.getLastSeenLabel('missing', 1800000000000), null)
|
||||
})
|
||||
|
||||
test('openTopic navigates to the encoded topic feed path', () => {
|
||||
const calls = []
|
||||
const page = new TopicDiscoveryPage({
|
||||
|
||||
@@ -207,6 +207,16 @@ async function loadFixture (world, name) {
|
||||
return
|
||||
}
|
||||
|
||||
if (name === 'topic-metadata-indexes') {
|
||||
await loadTopicMetadataIndexes(world)
|
||||
return
|
||||
}
|
||||
|
||||
if (name === 'rooms-with-topic-metadata') {
|
||||
await loadRoomsWithTopicMetadata(world)
|
||||
return
|
||||
}
|
||||
|
||||
if (name === 'rooms-with-topics-and-follows') {
|
||||
await loadRoomsWithTopicsAndFollows(world)
|
||||
return
|
||||
@@ -556,6 +566,42 @@ async function loadTopicIndexes (world) {
|
||||
}
|
||||
}
|
||||
|
||||
// Fixture "topic-metadata-indexes" from topic-metadata.feature: topic index
|
||||
// stores with lastSeen and followerCount already populated.
|
||||
async function loadTopicMetadataIndexes (world) {
|
||||
const summaries = [
|
||||
{ room: 'memo', postCount: 5, lastHeight: 600500, lastSeen: 1700020000000, followerCount: 12 },
|
||||
{ room: 'cash', postCount: 2, lastHeight: 600400, lastSeen: 1700010000000, followerCount: 4 },
|
||||
{ room: 'lone', postCount: 0, lastHeight: 0, lastSeen: 0, followerCount: 7 }
|
||||
]
|
||||
|
||||
for (const summary of summaries) {
|
||||
await world.adapters.level.topicSummariesDb.put(summary.room, summary)
|
||||
await world.adapters.level.topicRecencyDb.put(
|
||||
topicRecencyKey(summary.lastHeight, summary.room),
|
||||
{ room: summary.room, blockHeight: summary.lastHeight }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Fixture "rooms-with-topic-metadata" from topic-metadata.feature: raw rooms
|
||||
// store entries the backfill summarizes into lastSeen and followerCount.
|
||||
async function loadRoomsWithTopicMetadata (world) {
|
||||
const entries = [
|
||||
{ key: 'bitcoin:post-100', room: 'bitcoin', txid: 'post-100', type: 'post', blockHeight: 600100, seen: 1700000000000 },
|
||||
{ key: 'bitcoin:post-200', room: 'bitcoin', txid: 'post-200', type: 'post', blockHeight: 600200, seen: 1700009999000 },
|
||||
{ key: 'bitcoin:addr-f', room: 'bitcoin', addr: 'bitcoincash:qaddr-f', type: 'follow', unfollow: false },
|
||||
{ key: 'bitcoin:addr-g', room: 'bitcoin', addr: 'bitcoincash:qaddr-g', type: 'follow', unfollow: false },
|
||||
{ key: 'cash:post-250', room: 'cash', txid: 'post-250', type: 'post', blockHeight: 600250, seen: 1700012345000 },
|
||||
{ key: 'cash:addr-f', room: 'cash', addr: 'bitcoincash:qaddr-f', type: 'follow', unfollow: true },
|
||||
{ key: 'lone:addr-f', room: 'lone', addr: 'bitcoincash:qaddr-f', type: 'follow', unfollow: false }
|
||||
]
|
||||
|
||||
for (const entry of entries) {
|
||||
await world.adapters.level.roomsDb.put(entry.key, entry)
|
||||
}
|
||||
}
|
||||
|
||||
// Fixture "rooms-with-topics-and-follows" from backfill-topic-indexes.feature.
|
||||
async function loadRoomsWithTopicsAndFollows (world) {
|
||||
const entries = [
|
||||
@@ -1184,6 +1230,36 @@ const handlers = [
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'response contains topic last seen',
|
||||
pattern: /^the response contains the topic "(<topic>)" last seen at (<lastSeen>)$/,
|
||||
run (m, example, world) {
|
||||
const topic = resolveParam(m[1], example)
|
||||
const expected = parseInt(resolveParam(m[2], example), 10)
|
||||
const found = world.getLastResponse().topics.find((t) => t.room === topic)
|
||||
if (!found) {
|
||||
throw new Error(`Topic ${topic} not found in response`)
|
||||
}
|
||||
if (found.lastSeen !== expected) {
|
||||
throw new Error(`Expected lastSeen ${expected} for ${topic}, got ${found.lastSeen}`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'response contains topic follower count',
|
||||
pattern: /^the response contains the topic "(<topic>)" with (<followerCount>) followers?$/,
|
||||
run (m, example, world) {
|
||||
const topic = resolveParam(m[1], example)
|
||||
const expected = parseInt(resolveParam(m[2], example), 10)
|
||||
const found = world.getLastResponse().topics.find((t) => t.room === topic)
|
||||
if (!found) {
|
||||
throw new Error(`Topic ${topic} not found in response`)
|
||||
}
|
||||
if (found.followerCount !== expected) {
|
||||
throw new Error(`Expected followerCount ${expected} for ${topic}, got ${found.followerCount}`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'response lists topics in order',
|
||||
pattern: /^the response lists topics in order (<[A-Za-z0-9_]+>)$/,
|
||||
@@ -1366,6 +1442,36 @@ const handlers = [
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'topicSummaries records room last seen',
|
||||
pattern: /^the topicSummaries store records the room "(<room>)" last seen at (<lastSeen>)$/,
|
||||
async run (m, example, world) {
|
||||
const room = resolveParam(m[1], example)
|
||||
const expected = parseInt(resolveParam(m[2], example), 10)
|
||||
const record = await world.adapters.level.topicSummariesDb.get(room)
|
||||
if (!record) {
|
||||
throw new Error(`No topicSummaries record for ${room}`)
|
||||
}
|
||||
if (record.lastSeen !== expected) {
|
||||
throw new Error(`Expected ${room} lastSeen ${expected}, got ${JSON.stringify(record)}`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'topicSummaries records room follower count',
|
||||
pattern: /^the topicSummaries store records the room "(<room>)" with (<followerCount>) followers?$/,
|
||||
async run (m, example, world) {
|
||||
const room = resolveParam(m[1], example)
|
||||
const expected = parseInt(resolveParam(m[2], example), 10)
|
||||
const record = await world.adapters.level.topicSummariesDb.get(room)
|
||||
if (!record) {
|
||||
throw new Error(`No topicSummaries record for ${room}`)
|
||||
}
|
||||
if (record.followerCount !== expected) {
|
||||
throw new Error(`Expected ${room} followerCount ${expected}, got ${JSON.stringify(record)}`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'topicRecency records room at height',
|
||||
pattern: /^the topicRecency store records the room "(<room>)" at block height (<height>)$/,
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
Topic listing is served from two indexes so it can order and paginate
|
||||
without iterating the rooms store:
|
||||
- topicSummaries: one record per room keyed by room, value
|
||||
{ room, postCount, lastHeight }.
|
||||
{ room, postCount, lastHeight, lastSeen, followerCount }.
|
||||
- topicRecency: one record per room keyed `${invertedHeight}:${room}`, value
|
||||
{ room, blockHeight }. Follow-only rooms live at height 0. The height is
|
||||
inverted so an ascending scan yields newest-first.
|
||||
@@ -84,15 +84,16 @@ class TopicQuery {
|
||||
}
|
||||
|
||||
// Return a page of topics ordered by their most recent post, descending,
|
||||
// with rooms at the same height ordered by name ascending. Counts and the
|
||||
// total come from topicSummaries; ordering and pagination come from
|
||||
// topicRecency, which is read only through offset + limit records.
|
||||
// with rooms at the same height ordered by name ascending. Post count,
|
||||
// last-seen time, follower count, and the total come from topicSummaries;
|
||||
// ordering and pagination come from topicRecency, which is read only
|
||||
// through offset + limit records.
|
||||
async listTopics ({ limit = 100, offset = 0 } = {}) {
|
||||
const postCounts = new Map()
|
||||
const summaries = new Map()
|
||||
for await (const [key, value] of this.topicSummariesDb.iterator()) {
|
||||
postCounts.set(this.summaryRoom(key, value), value?.postCount ?? 0)
|
||||
summaries.set(this.summaryRoom(key, value), value)
|
||||
}
|
||||
const total = postCounts.size
|
||||
const total = summaries.size
|
||||
|
||||
const recencyRooms = []
|
||||
for await (const [key, value] of this.topicRecencyDb.iterator({ limit: offset + limit })) {
|
||||
@@ -100,10 +101,15 @@ class TopicQuery {
|
||||
}
|
||||
|
||||
const pageRooms = recencyRooms.slice(offset, offset + limit)
|
||||
const topics = pageRooms.map((room) => ({
|
||||
room,
|
||||
postCount: postCounts.get(room) ?? 0
|
||||
}))
|
||||
const topics = pageRooms.map((room) => {
|
||||
const summary = summaries.get(room)
|
||||
return {
|
||||
room,
|
||||
postCount: summary?.postCount ?? 0,
|
||||
lastSeen: summary?.lastSeen ?? 0,
|
||||
followerCount: summary?.followerCount ?? 0
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
topics,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
The read side serves GET /topics from these two indexes:
|
||||
- topicSummaries: one record per room keyed by room, value
|
||||
{ room, postCount, lastHeight }.
|
||||
{ room, postCount, lastHeight, lastSeen, followerCount }.
|
||||
- topicRecency: one record per room keyed `${invertedHeight}:${room}`,
|
||||
value { room, blockHeight }. Follow-only rooms live at height 0. The
|
||||
height is inverted so an ascending scan yields newest-first.
|
||||
@@ -35,11 +35,21 @@ function roomFromEntry (key, value) {
|
||||
return String(key).split(':')[0]
|
||||
}
|
||||
|
||||
// Fold one post entry into its room summary, tracking the newest height.
|
||||
// Fold one post entry into its room summary, tracking the newest height and
|
||||
// newest seen time.
|
||||
function applyPost (summary, value) {
|
||||
summary.postCount++
|
||||
const height = value.blockHeight ?? 0
|
||||
if (height > summary.lastHeight) summary.lastHeight = height
|
||||
const seen = value.seen ?? 0
|
||||
if (seen > summary.lastSeen) summary.lastSeen = seen
|
||||
}
|
||||
|
||||
// Count one active follow. Each address has at most one follow record per
|
||||
// room, so the number of active follow records is the follower count.
|
||||
function applyFollow (summary, value) {
|
||||
if (value?.unfollow === true) return
|
||||
summary.followerCount++
|
||||
}
|
||||
|
||||
async function collectSummaries (roomsDb) {
|
||||
@@ -49,11 +59,13 @@ async function collectSummaries (roomsDb) {
|
||||
const room = roomFromEntry(key, value)
|
||||
if (!room) continue
|
||||
if (!summaries.has(room)) {
|
||||
summaries.set(room, { room, postCount: 0, lastHeight: 0 })
|
||||
summaries.set(room, { room, postCount: 0, lastHeight: 0, lastSeen: 0, followerCount: 0 })
|
||||
}
|
||||
|
||||
if (value?.type === 'post') {
|
||||
applyPost(summaries.get(room), value)
|
||||
} else if (value?.type === 'follow') {
|
||||
applyFollow(summaries.get(room), value)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -166,12 +166,12 @@ describe('#TopicQuery', () => {
|
||||
|
||||
describe('#listTopics', () => {
|
||||
const summaries = [
|
||||
['memo', { room: 'memo', postCount: 5, lastHeight: 600500 }],
|
||||
['cash', { room: 'cash', postCount: 2, lastHeight: 600400 }],
|
||||
['dance', { room: 'dance', postCount: 3, lastHeight: 600400 }],
|
||||
['anime', { room: 'anime', postCount: 1, lastHeight: 600300 }],
|
||||
['lone', { room: 'lone', postCount: 0, lastHeight: 0 }],
|
||||
['quiet', { room: 'quiet', postCount: 0, lastHeight: 0 }]
|
||||
['memo', { room: 'memo', postCount: 5, lastHeight: 600500, lastSeen: 1700020000000, followerCount: 12 }],
|
||||
['cash', { room: 'cash', postCount: 2, lastHeight: 600400, lastSeen: 1700010000000, followerCount: 4 }],
|
||||
['dance', { room: 'dance', postCount: 3, lastHeight: 600400, lastSeen: 1700008000000, followerCount: 1 }],
|
||||
['anime', { room: 'anime', postCount: 1, lastHeight: 600300, lastSeen: 1700006000000, followerCount: 0 }],
|
||||
['lone', { room: 'lone', postCount: 0, lastHeight: 0, lastSeen: 0, followerCount: 7 }],
|
||||
['quiet', { room: 'quiet', postCount: 0, lastHeight: 0, lastSeen: 0, followerCount: 0 }]
|
||||
]
|
||||
const recency = [
|
||||
[topicRecencyKey(600500, 'memo'), { room: 'memo', blockHeight: 600500 }],
|
||||
@@ -193,16 +193,35 @@ describe('#TopicQuery', () => {
|
||||
const result = await uut.listTopics({ limit: 100, offset: 0 })
|
||||
|
||||
assert.deepEqual(result.topics, [
|
||||
{ room: 'memo', postCount: 5 },
|
||||
{ room: 'cash', postCount: 2 },
|
||||
{ room: 'dance', postCount: 3 },
|
||||
{ room: 'anime', postCount: 1 },
|
||||
{ room: 'lone', postCount: 0 },
|
||||
{ room: 'quiet', postCount: 0 }
|
||||
{ room: 'memo', postCount: 5, lastSeen: 1700020000000, followerCount: 12 },
|
||||
{ room: 'cash', postCount: 2, lastSeen: 1700010000000, followerCount: 4 },
|
||||
{ room: 'dance', postCount: 3, lastSeen: 1700008000000, followerCount: 1 },
|
||||
{ room: 'anime', postCount: 1, lastSeen: 1700006000000, followerCount: 0 },
|
||||
{ room: 'lone', postCount: 0, lastSeen: 0, followerCount: 7 },
|
||||
{ room: 'quiet', postCount: 0, lastSeen: 0, followerCount: 0 }
|
||||
])
|
||||
assert.deepEqual(result.pagination, { limit: 100, offset: 0, total: 6, hasMore: false })
|
||||
})
|
||||
|
||||
it('should default missing metadata fields to zero for legacy summaries', async () => {
|
||||
uut = new TopicQuery({
|
||||
roomsDb,
|
||||
postsDb,
|
||||
topicSummariesDb: makeIteratorDb([
|
||||
['memo', { room: 'memo', postCount: 5, lastHeight: 600500 }]
|
||||
]),
|
||||
topicRecencyDb: makeIteratorDb([
|
||||
[topicRecencyKey(600500, 'memo'), { room: 'memo', blockHeight: 600500 }]
|
||||
])
|
||||
})
|
||||
|
||||
const result = await uut.listTopics({ limit: 100, offset: 0 })
|
||||
|
||||
assert.deepEqual(result.topics, [
|
||||
{ room: 'memo', postCount: 5, lastSeen: 0, followerCount: 0 }
|
||||
])
|
||||
})
|
||||
|
||||
it('should paginate using recency order and report total and hasMore', async () => {
|
||||
uut = new TopicQuery({
|
||||
roomsDb,
|
||||
|
||||
@@ -30,10 +30,10 @@ function makeDb (records = []) {
|
||||
describe('#backfillTopicIndexes', () => {
|
||||
it('should summarize rooms with posts and follow-only rooms', async () => {
|
||||
const roomsDb = makeDb([
|
||||
['bitcoin:post-100', { room: 'bitcoin', txid: 'post-100', type: 'post', blockHeight: 600100 }],
|
||||
['bitcoin:post-200', { room: 'bitcoin', txid: 'post-200', type: 'post', blockHeight: 600200 }],
|
||||
['bitcoin:post-100', { room: 'bitcoin', txid: 'post-100', type: 'post', blockHeight: 600100, seen: 1700000000000 }],
|
||||
['bitcoin:post-200', { room: 'bitcoin', txid: 'post-200', type: 'post', blockHeight: 600200, seen: 1700000999000 }],
|
||||
['bitcoin:addr-f', { room: 'bitcoin', addr: 'addr-f', type: 'follow', unfollow: false }],
|
||||
['cash:post-250', { room: 'cash', txid: 'post-250', type: 'post', blockHeight: 600250 }],
|
||||
['cash:post-250', { room: 'cash', txid: 'post-250', type: 'post', blockHeight: 600250, seen: 1700002000000 }],
|
||||
['lone:addr-f', { room: 'lone', addr: 'addr-f', type: 'follow', unfollow: false }]
|
||||
])
|
||||
const topicSummariesDb = makeDb()
|
||||
@@ -42,9 +42,27 @@ describe('#backfillTopicIndexes', () => {
|
||||
const result = await backfillTopicIndexes({ roomsDb, topicSummariesDb, topicRecencyDb })
|
||||
|
||||
assert.equal(result.rooms, 3)
|
||||
assert.deepEqual(topicSummariesDb.store.get('bitcoin'), { room: 'bitcoin', postCount: 2, lastHeight: 600200 })
|
||||
assert.deepEqual(topicSummariesDb.store.get('cash'), { room: 'cash', postCount: 1, lastHeight: 600250 })
|
||||
assert.deepEqual(topicSummariesDb.store.get('lone'), { room: 'lone', postCount: 0, lastHeight: 0 })
|
||||
assert.deepEqual(topicSummariesDb.store.get('bitcoin'), {
|
||||
room: 'bitcoin',
|
||||
postCount: 2,
|
||||
lastHeight: 600200,
|
||||
lastSeen: 1700000999000,
|
||||
followerCount: 1
|
||||
})
|
||||
assert.deepEqual(topicSummariesDb.store.get('cash'), {
|
||||
room: 'cash',
|
||||
postCount: 1,
|
||||
lastHeight: 600250,
|
||||
lastSeen: 1700002000000,
|
||||
followerCount: 0
|
||||
})
|
||||
assert.deepEqual(topicSummariesDb.store.get('lone'), {
|
||||
room: 'lone',
|
||||
postCount: 0,
|
||||
lastHeight: 0,
|
||||
lastSeen: 0,
|
||||
followerCount: 1
|
||||
})
|
||||
|
||||
assert.deepEqual(topicRecencyDb.store.get(topicRecencyKey(600200, 'bitcoin')), { room: 'bitcoin', blockHeight: 600200 })
|
||||
assert.deepEqual(topicRecencyDb.store.get(topicRecencyKey(600250, 'cash')), { room: 'cash', blockHeight: 600250 })
|
||||
@@ -52,9 +70,38 @@ describe('#backfillTopicIndexes', () => {
|
||||
assert.equal(topicRecencyDb.store.size, 3)
|
||||
})
|
||||
|
||||
it('should count only active follows and ignore unfollowed addresses', async () => {
|
||||
const roomsDb = makeDb([
|
||||
['bitcoin:addr-a', { room: 'bitcoin', addr: 'addr-a', type: 'follow', unfollow: false }],
|
||||
['bitcoin:addr-b', { room: 'bitcoin', addr: 'addr-b', type: 'follow', unfollow: true }],
|
||||
['bitcoin:addr-c', { room: 'bitcoin', addr: 'addr-c', type: 'follow', unfollow: false }]
|
||||
])
|
||||
const topicSummariesDb = makeDb()
|
||||
const topicRecencyDb = makeDb()
|
||||
|
||||
await backfillTopicIndexes({ roomsDb, topicSummariesDb, topicRecencyDb })
|
||||
|
||||
assert.equal(topicSummariesDb.store.get('bitcoin').followerCount, 2)
|
||||
})
|
||||
|
||||
it('should keep the newest lastSeen when post heights and seen times disagree', async () => {
|
||||
const roomsDb = makeDb([
|
||||
['bitcoin:post-100', { room: 'bitcoin', txid: 'post-100', type: 'post', blockHeight: 600100, seen: 1700009999000 }],
|
||||
['bitcoin:post-200', { room: 'bitcoin', txid: 'post-200', type: 'post', blockHeight: 600200, seen: 1700000000000 }]
|
||||
])
|
||||
const topicSummariesDb = makeDb()
|
||||
const topicRecencyDb = makeDb()
|
||||
|
||||
await backfillTopicIndexes({ roomsDb, topicSummariesDb, topicRecencyDb })
|
||||
|
||||
const summary = topicSummariesDb.store.get('bitcoin')
|
||||
assert.equal(summary.lastHeight, 600200)
|
||||
assert.equal(summary.lastSeen, 1700009999000)
|
||||
})
|
||||
|
||||
it('should be idempotent across repeated runs', async () => {
|
||||
const roomsDb = makeDb([
|
||||
['bitcoin:post-200', { room: 'bitcoin', txid: 'post-200', type: 'post', blockHeight: 600200 }],
|
||||
['bitcoin:post-200', { room: 'bitcoin', txid: 'post-200', type: 'post', blockHeight: 600200, seen: 1700003000000 }],
|
||||
['lone:addr-f', { room: 'lone', addr: 'addr-f', type: 'follow', unfollow: false }]
|
||||
])
|
||||
const topicSummariesDb = makeDb()
|
||||
@@ -72,7 +119,7 @@ describe('#backfillTopicIndexes', () => {
|
||||
|
||||
it('should remove stale recency records from a previous run', async () => {
|
||||
const roomsDb = makeDb([
|
||||
['bitcoin:post-200', { room: 'bitcoin', txid: 'post-200', type: 'post', blockHeight: 600200 }]
|
||||
['bitcoin:post-200', { room: 'bitcoin', txid: 'post-200', type: 'post', blockHeight: 600200, seen: 1700000000000 }]
|
||||
])
|
||||
const topicSummariesDb = makeDb()
|
||||
const topicRecencyDb = makeDb([
|
||||
@@ -96,7 +143,13 @@ describe('#backfillTopicIndexes', () => {
|
||||
|
||||
await backfillTopicIndexes({ roomsDb, topicSummariesDb, topicRecencyDb })
|
||||
|
||||
assert.deepEqual(topicSummariesDb.store.get('cash'), { room: 'cash', postCount: 1, lastHeight: 500 })
|
||||
assert.deepEqual(topicSummariesDb.store.get('cash'), {
|
||||
room: 'cash',
|
||||
postCount: 1,
|
||||
lastHeight: 500,
|
||||
lastSeen: 0,
|
||||
followerCount: 0
|
||||
})
|
||||
assert.deepEqual(topicRecencyDb.store.get(topicRecencyKey(500, 'cash')), { room: 'cash', blockHeight: 500 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -719,13 +719,14 @@ const muteHandlers = [
|
||||
},
|
||||
{
|
||||
name: 'process a Memo topic message',
|
||||
pattern: /^the indexer processes a Memo topic message (.+) in room "(.+)" from (.+) at block height (.+) with text "(.+)"$/,
|
||||
pattern: /^the indexer processes a Memo topic message (.+) in room "(.+)" from (.+) at block height (.+) with text "(.+)"(?: seen at (.+))?$/,
|
||||
async run (m, example, world) {
|
||||
const txid = resolveTxid(m[1], example, world)
|
||||
const room = m[2]
|
||||
const room = resolveParam(m[2], example)
|
||||
const addr = resolveParam(m[3], example)
|
||||
const height = parseInt(resolveParam(m[4], example), 10)
|
||||
const text = m[5]
|
||||
const seen = m[6] ? parseInt(resolveParam(m[6], example), 10) : Date.now()
|
||||
|
||||
world.lastTxid = txid
|
||||
world.lastHeight = height
|
||||
@@ -736,7 +737,7 @@ const muteHandlers = [
|
||||
adapters: world.adapters,
|
||||
txid,
|
||||
signerAddr: addr,
|
||||
seen: Date.now(),
|
||||
seen,
|
||||
blockHeight: height,
|
||||
decoded: {
|
||||
action: 'topicMessage',
|
||||
@@ -763,7 +764,7 @@ const muteHandlers = [
|
||||
name: 'process a Memo topic follow',
|
||||
pattern: /^the indexer processes a Memo topic follow for room "(.+)" from (.+)$/,
|
||||
async run (m, example, world) {
|
||||
const room = m[1]
|
||||
const room = resolveParam(m[1], example)
|
||||
const addr = resolveParam(m[2], example)
|
||||
const txid = deriveTxid(`topic-follow-${room}-${addr}`)
|
||||
|
||||
@@ -771,7 +772,7 @@ const muteHandlers = [
|
||||
world.lastAddr = addr
|
||||
|
||||
const prefix = Buffer.from('6d0d', 'hex')
|
||||
await handleTopicFollow({
|
||||
const ctx = {
|
||||
adapters: world.adapters,
|
||||
txid,
|
||||
signerAddr: addr,
|
||||
@@ -782,14 +783,56 @@ const muteHandlers = [
|
||||
prefix,
|
||||
pushDatas: [prefix, Buffer.from(room, 'utf8')]
|
||||
}
|
||||
})
|
||||
}
|
||||
world.lastTopicFollow = ctx
|
||||
|
||||
await handleTopicFollow(ctx)
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'process a Memo topic unfollow',
|
||||
pattern: /^the indexer processes a Memo topic unfollow for room "(.+)" from (.+)$/,
|
||||
async run (m, example, world) {
|
||||
const room = resolveParam(m[1], example)
|
||||
const addr = resolveParam(m[2], example)
|
||||
const txid = deriveTxid(`topic-unfollow-${room}-${addr}`)
|
||||
|
||||
world.lastTxid = txid
|
||||
world.lastAddr = addr
|
||||
|
||||
const prefix = Buffer.from('6d0e', 'hex')
|
||||
const ctx = {
|
||||
adapters: world.adapters,
|
||||
txid,
|
||||
signerAddr: addr,
|
||||
seen: Date.now(),
|
||||
blockHeight: 600000,
|
||||
decoded: {
|
||||
action: 'topicUnfollow',
|
||||
prefix,
|
||||
pushDatas: [prefix, Buffer.from(room, 'utf8')]
|
||||
}
|
||||
}
|
||||
world.lastTopicFollow = ctx
|
||||
|
||||
await handleTopicFollow(ctx)
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'reprocess the last Memo topic follow',
|
||||
pattern: /^the indexer processes the same Memo topic follow for room "(.+)" from (.+) again$/,
|
||||
async run (m, example, world) {
|
||||
if (!world.lastTopicFollow) {
|
||||
throw new Error('No previous topic follow to reprocess')
|
||||
}
|
||||
await handleTopicFollow(world.lastTopicFollow)
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'topicSummaries contains room with postCount and lastHeight',
|
||||
pattern: /^the topicSummaries store contains the room "(.+)" with postCount (.+) and lastHeight (.+)$/,
|
||||
async run (m, example, world) {
|
||||
const room = m[1]
|
||||
const room = resolveParam(m[1], example)
|
||||
const postCount = parseInt(resolveParam(m[2], example), 10)
|
||||
const lastHeight = parseInt(resolveParam(m[3], example), 10)
|
||||
const record = await world.topicSummariesDb.get(room)
|
||||
@@ -801,11 +844,41 @@ const muteHandlers = [
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'topicSummaries records room last seen',
|
||||
pattern: /^the topicSummaries store records the room "(.+)" last seen at (.+)$/,
|
||||
async run (m, example, world) {
|
||||
const room = resolveParam(m[1], example)
|
||||
const lastSeen = parseInt(resolveParam(m[2], example), 10)
|
||||
const record = await world.topicSummariesDb.get(room)
|
||||
if (!record) {
|
||||
throw new Error(`No topicSummaries record for ${room}`)
|
||||
}
|
||||
if (record.lastSeen !== lastSeen) {
|
||||
throw new Error(`Expected ${room} lastSeen ${lastSeen}, got ${JSON.stringify(record)}`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'topicSummaries records room follower count',
|
||||
pattern: /^the topicSummaries store records the room "(.+)" with (.+?) followers?$/,
|
||||
async run (m, example, world) {
|
||||
const room = resolveParam(m[1], example)
|
||||
const followerCount = parseInt(resolveParam(m[2], example), 10)
|
||||
const record = await world.topicSummariesDb.get(room)
|
||||
if (!record) {
|
||||
throw new Error(`No topicSummaries record for ${room}`)
|
||||
}
|
||||
if (record.followerCount !== followerCount) {
|
||||
throw new Error(`Expected ${room} followerCount ${followerCount}, got ${JSON.stringify(record)}`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'topicRecency records room at height',
|
||||
pattern: /^the topicRecency store records the room "(.+)" at block height (.+)$/,
|
||||
async run (m, example, world) {
|
||||
const room = m[1]
|
||||
const room = resolveParam(m[1], example)
|
||||
const height = parseInt(resolveParam(m[2], example), 10)
|
||||
const record = await world.topicRecencyDb.get(topicRecencyKey(height, room))
|
||||
if (!record) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { utf8FromPush, logProcessError, roomKey } from './helpers.js'
|
||||
import { utf8FromPush, logProcessError } from './helpers.js'
|
||||
import { PREFIX_TOPIC_UNFOLLOW } from '../../lib/memo-codes.js'
|
||||
import { ensureTopicRoom } from './topic-indexing.js'
|
||||
import { recordTopicFollow } from './topic-indexing.js'
|
||||
|
||||
export async function handleTopicFollow (ctx) {
|
||||
const { adapters, txid, signerAddr, decoded, seen, blockHeight } = ctx
|
||||
@@ -14,7 +14,7 @@ export async function handleTopicFollow (ctx) {
|
||||
const room = utf8FromPush(pushDatas[1])
|
||||
const unfollow = prefix[1] === PREFIX_TOPIC_UNFOLLOW[1]
|
||||
|
||||
await adapters.roomDb.create(roomKey(room, signerAddr), {
|
||||
await recordTopicFollow(adapters, {
|
||||
room,
|
||||
addr: signerAddr,
|
||||
unfollow,
|
||||
@@ -23,10 +23,6 @@ export async function handleTopicFollow (ctx) {
|
||||
type: 'follow',
|
||||
blockHeight
|
||||
})
|
||||
|
||||
if (!unfollow) {
|
||||
await ensureTopicRoom(adapters, room)
|
||||
}
|
||||
}
|
||||
|
||||
// mutate4javascript-manifest-begin
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Maintain the topicSummaries and topicRecency indexes for Memo topic activity.
|
||||
|
||||
topicSummaries: one record per room keyed by room, value
|
||||
{ room, postCount, lastHeight }.
|
||||
{ room, postCount, lastHeight, lastSeen, followerCount }.
|
||||
topicRecency: one record per room keyed by `${invertedHeight}:${room}`, value
|
||||
{ room, blockHeight }. Follow-only rooms live at height 0. The height is
|
||||
inverted so an ascending scan yields newest-first.
|
||||
@@ -11,20 +11,26 @@
|
||||
re-running an action is safe.
|
||||
*/
|
||||
|
||||
import { getIfPresent, topicRecencyKey } from './helpers.js'
|
||||
import { getIfPresent, roomKey, topicRecencyKey } from './helpers.js'
|
||||
|
||||
// Add a post to the room's summary and move its recency record to the newest
|
||||
// height. A room that is reprocessed is filtered out before this runs, so the
|
||||
// post count is not double-counted.
|
||||
export async function recordTopicPost (adapters, room, blockHeight) {
|
||||
// post count is not double-counted. lastSeen tracks the newest post `seen`
|
||||
// time while lastHeight tracks the newest block height, so a replayed block
|
||||
// with out-of-order timestamps still converges.
|
||||
export async function recordTopicPost (adapters, room, blockHeight, seen) {
|
||||
const height = blockHeight ?? 0
|
||||
const seenAt = seen ?? 0
|
||||
const summary = await getIfPresent(adapters.topicSummaryDb, room)
|
||||
const lastHeight = Math.max(summary?.lastHeight ?? 0, height)
|
||||
const lastSeen = Math.max(summary?.lastSeen ?? 0, seenAt)
|
||||
|
||||
await adapters.topicSummaryDb.update(room, {
|
||||
room,
|
||||
postCount: (summary?.postCount ?? 0) + 1,
|
||||
lastHeight
|
||||
lastHeight,
|
||||
lastSeen,
|
||||
followerCount: summary?.followerCount ?? 0
|
||||
})
|
||||
|
||||
// When the room moved to a newer height, delete the stale recency record so
|
||||
@@ -40,13 +46,46 @@ export async function recordTopicPost (adapters, room, blockHeight) {
|
||||
}
|
||||
|
||||
// Give a follow-only room a zero-post summary and recency record. A room that
|
||||
// already has a summary (because it has posts) is left unchanged.
|
||||
// already has a summary (because it has posts) is left unchanged. Returns the
|
||||
// existing or created summary.
|
||||
export async function ensureTopicRoom (adapters, room) {
|
||||
const summary = await getIfPresent(adapters.topicSummaryDb, room)
|
||||
if (summary) return
|
||||
if (summary) return summary
|
||||
|
||||
await adapters.topicSummaryDb.update(room, { room, postCount: 0, lastHeight: 0 })
|
||||
const created = { room, postCount: 0, lastHeight: 0, lastSeen: 0, followerCount: 0 }
|
||||
await adapters.topicSummaryDb.update(room, created)
|
||||
await adapters.topicRecencyDb.update(topicRecencyKey(0, room), { room, blockHeight: 0 })
|
||||
return created
|
||||
}
|
||||
|
||||
// Apply a topic follow or unfollow to the room's summary. The follower count
|
||||
// changes only when the address's follow state actually flips, so replaying a
|
||||
// follow (or unfollow) is idempotent. The room record is upserted so the
|
||||
// previous state is available to compute the delta.
|
||||
export async function recordTopicFollow (adapters, record) {
|
||||
const { room, addr, unfollow } = record
|
||||
const key = roomKey(room, addr)
|
||||
const previous = await getIfPresent(adapters.roomDb, key)
|
||||
const wasActive = previous?.type === 'follow' && previous.unfollow !== true
|
||||
const isActive = !unfollow
|
||||
|
||||
await adapters.roomDb.update(key, record)
|
||||
|
||||
const existing = await getIfPresent(adapters.topicSummaryDb, room)
|
||||
// An unfollow for a room that was never summarized is a no-op and must not
|
||||
// create a zero-post room.
|
||||
if (!existing && !isActive) return null
|
||||
|
||||
const summary = existing || await ensureTopicRoom(adapters, room)
|
||||
const delta = (isActive ? 1 : 0) - (wasActive ? 1 : 0)
|
||||
if (delta === 0) return summary
|
||||
|
||||
const updated = {
|
||||
...summary,
|
||||
followerCount: Math.max(0, (summary.followerCount ?? 0) + delta)
|
||||
}
|
||||
await adapters.topicSummaryDb.update(room, updated)
|
||||
return updated
|
||||
}
|
||||
|
||||
// mutate4javascript-manifest-begin
|
||||
|
||||
@@ -33,7 +33,7 @@ export async function handleTopicMessage (ctx) {
|
||||
await adapters.roomDb.create(key, { room, txid, seen, type: 'post', blockHeight })
|
||||
|
||||
if (!alreadyIndexed) {
|
||||
await recordTopicPost(adapters, room, blockHeight)
|
||||
await recordTopicPost(adapters, room, blockHeight, seen)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ async function processFollow (adapters, { txid, room, addr, prefix = PREFIX_TOPI
|
||||
}
|
||||
|
||||
describe('#handleTopicFollow topic indexes', () => {
|
||||
it('should record a zero-post room for a follow-only room', async () => {
|
||||
it('should record a zero-post room with one follower for a follow-only room', async () => {
|
||||
const adapters = makeAdapters()
|
||||
|
||||
await processFollow(adapters, { txid: 'follow-1', room: 'lone', addr: 'bitcoincash:qaddr-a' })
|
||||
@@ -63,7 +63,9 @@ describe('#handleTopicFollow topic indexes', () => {
|
||||
assert.deepEqual(adapters.topicSummaryDb.store.get('lone'), {
|
||||
room: 'lone',
|
||||
postCount: 0,
|
||||
lastHeight: 0
|
||||
lastHeight: 0,
|
||||
lastSeen: 0,
|
||||
followerCount: 1
|
||||
})
|
||||
assert.deepEqual(adapters.topicRecencyDb.store.get(topicRecencyKey(0, 'lone')), {
|
||||
room: 'lone',
|
||||
@@ -71,9 +73,15 @@ describe('#handleTopicFollow topic indexes', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should leave an existing room summary unchanged', async () => {
|
||||
it('should preserve post metadata and increase the follower count for an existing room', async () => {
|
||||
const adapters = makeAdapters()
|
||||
adapters.topicSummaryDb.store.set('bitcoin', { room: 'bitcoin', postCount: 1, lastHeight: 600400 })
|
||||
adapters.topicSummaryDb.store.set('bitcoin', {
|
||||
room: 'bitcoin',
|
||||
postCount: 1,
|
||||
lastHeight: 600400,
|
||||
lastSeen: 1700012345000,
|
||||
followerCount: 0
|
||||
})
|
||||
adapters.topicRecencyDb.store.set(topicRecencyKey(600400, 'bitcoin'), { room: 'bitcoin', blockHeight: 600400 })
|
||||
|
||||
await processFollow(adapters, { txid: 'follow-2', room: 'bitcoin', addr: 'bitcoincash:qaddr-a' })
|
||||
@@ -81,7 +89,9 @@ describe('#handleTopicFollow topic indexes', () => {
|
||||
assert.deepEqual(adapters.topicSummaryDb.store.get('bitcoin'), {
|
||||
room: 'bitcoin',
|
||||
postCount: 1,
|
||||
lastHeight: 600400
|
||||
lastHeight: 600400,
|
||||
lastSeen: 1700012345000,
|
||||
followerCount: 1
|
||||
})
|
||||
assert.deepEqual(adapters.topicRecencyDb.store.get(topicRecencyKey(600400, 'bitcoin')), {
|
||||
room: 'bitcoin',
|
||||
@@ -90,7 +100,7 @@ describe('#handleTopicFollow topic indexes', () => {
|
||||
assert.isFalse(adapters.topicRecencyDb.store.has(topicRecencyKey(0, 'bitcoin')))
|
||||
})
|
||||
|
||||
it('should not create a zero-post room for an unfollow', async () => {
|
||||
it('should not create a zero-post room for an unfollow with no prior summary', async () => {
|
||||
const adapters = makeAdapters()
|
||||
|
||||
await processFollow(adapters, {
|
||||
@@ -104,6 +114,71 @@ describe('#handleTopicFollow topic indexes', () => {
|
||||
assert.equal(adapters.topicRecencyDb.store.size, 0)
|
||||
})
|
||||
|
||||
it('should count two distinct followers', async () => {
|
||||
const adapters = makeAdapters()
|
||||
|
||||
await processFollow(adapters, { txid: 'follow-3', room: 'bitcoin', addr: 'bitcoincash:qaddr-a' })
|
||||
await processFollow(adapters, { txid: 'follow-4', room: 'bitcoin', addr: 'bitcoincash:qaddr-b' })
|
||||
|
||||
assert.equal(adapters.topicSummaryDb.store.get('bitcoin').followerCount, 2)
|
||||
})
|
||||
|
||||
it('should decrease the follower count on an unfollow', async () => {
|
||||
const adapters = makeAdapters()
|
||||
|
||||
await processFollow(adapters, { txid: 'follow-5', room: 'bitcoin', addr: 'bitcoincash:qaddr-a' })
|
||||
await processFollow(adapters, { txid: 'follow-6', room: 'bitcoin', addr: 'bitcoincash:qaddr-b' })
|
||||
await processFollow(adapters, {
|
||||
txid: 'unfollow-2',
|
||||
room: 'bitcoin',
|
||||
addr: 'bitcoincash:qaddr-a',
|
||||
prefix: PREFIX_TOPIC_UNFOLLOW
|
||||
})
|
||||
|
||||
assert.equal(adapters.topicSummaryDb.store.get('bitcoin').followerCount, 1)
|
||||
})
|
||||
|
||||
it('should not change the follower count when a follow is reprocessed', async () => {
|
||||
const adapters = makeAdapters()
|
||||
|
||||
await processFollow(adapters, { txid: 'follow-7', room: 'bitcoin', addr: 'bitcoincash:qaddr-a' })
|
||||
await processFollow(adapters, { txid: 'follow-7', room: 'bitcoin', addr: 'bitcoincash:qaddr-a' })
|
||||
|
||||
assert.equal(adapters.topicSummaryDb.store.get('bitcoin').followerCount, 1)
|
||||
})
|
||||
|
||||
it('should not double-decrement when an unfollow is reprocessed', async () => {
|
||||
const adapters = makeAdapters()
|
||||
|
||||
await processFollow(adapters, { txid: 'follow-8', room: 'bitcoin', addr: 'bitcoincash:qaddr-a' })
|
||||
await processFollow(adapters, { txid: 'follow-9', room: 'bitcoin', addr: 'bitcoincash:qaddr-b' })
|
||||
const unfollow = {
|
||||
txid: 'unfollow-3',
|
||||
room: 'bitcoin',
|
||||
addr: 'bitcoincash:qaddr-a',
|
||||
prefix: PREFIX_TOPIC_UNFOLLOW
|
||||
}
|
||||
await processFollow(adapters, unfollow)
|
||||
await processFollow(adapters, unfollow)
|
||||
|
||||
assert.equal(adapters.topicSummaryDb.store.get('bitcoin').followerCount, 1)
|
||||
})
|
||||
|
||||
it('should let a re-follow increase the count after an unfollow', async () => {
|
||||
const adapters = makeAdapters()
|
||||
|
||||
await processFollow(adapters, { txid: 'follow-10', room: 'bitcoin', addr: 'bitcoincash:qaddr-a' })
|
||||
await processFollow(adapters, {
|
||||
txid: 'unfollow-4',
|
||||
room: 'bitcoin',
|
||||
addr: 'bitcoincash:qaddr-a',
|
||||
prefix: PREFIX_TOPIC_UNFOLLOW
|
||||
})
|
||||
await processFollow(adapters, { txid: 'follow-11', room: 'bitcoin', addr: 'bitcoincash:qaddr-a' })
|
||||
|
||||
assert.equal(adapters.topicSummaryDb.store.get('bitcoin').followerCount, 1)
|
||||
})
|
||||
|
||||
it('should log a process error and write nothing when the push data count is invalid', async () => {
|
||||
const adapters = makeAdapters()
|
||||
|
||||
|
||||
@@ -42,7 +42,9 @@ describe('#recordTopicPost height fallbacks', () => {
|
||||
assert.deepEqual(adapters.topicSummaryDb.store.get('bitcoin'), {
|
||||
room: 'bitcoin',
|
||||
postCount: 1,
|
||||
lastHeight: 0
|
||||
lastHeight: 0,
|
||||
lastSeen: 0,
|
||||
followerCount: 0
|
||||
})
|
||||
assert.deepEqual(adapters.topicRecencyDb.store.get(topicRecencyKey(0, 'bitcoin')), {
|
||||
room: 'bitcoin',
|
||||
@@ -65,7 +67,9 @@ describe('#recordTopicPost height fallbacks', () => {
|
||||
assert.deepEqual(adapters.topicSummaryDb.store.get('bitcoin'), {
|
||||
room: 'bitcoin',
|
||||
postCount: 3,
|
||||
lastHeight: 600100
|
||||
lastHeight: 600100,
|
||||
lastSeen: 0,
|
||||
followerCount: 0
|
||||
})
|
||||
})
|
||||
|
||||
@@ -83,3 +87,48 @@ describe('#recordTopicPost height fallbacks', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('#recordTopicPost lastSeen', () => {
|
||||
it('should record the post seen time as lastSeen', async () => {
|
||||
const adapters = makeAdapters()
|
||||
|
||||
await recordTopicPost(adapters, 'bitcoin', 600100, 1700000000000)
|
||||
|
||||
assert.equal(adapters.topicSummaryDb.store.get('bitcoin').lastSeen, 1700000000000)
|
||||
})
|
||||
|
||||
it('should keep the newest lastSeen when a later post has an earlier seen time', async () => {
|
||||
const adapters = makeAdapters()
|
||||
|
||||
await recordTopicPost(adapters, 'bitcoin', 600200, 1700009999000)
|
||||
await recordTopicPost(adapters, 'bitcoin', 600100, 1700000000000)
|
||||
|
||||
const summary = adapters.topicSummaryDb.store.get('bitcoin')
|
||||
assert.equal(summary.lastSeen, 1700009999000)
|
||||
assert.equal(summary.lastHeight, 600200)
|
||||
assert.equal(summary.postCount, 2)
|
||||
})
|
||||
|
||||
it('should default lastSeen to zero when seen is missing', async () => {
|
||||
const adapters = makeAdapters()
|
||||
|
||||
await recordTopicPost(adapters, 'bitcoin', 600100)
|
||||
|
||||
assert.equal(adapters.topicSummaryDb.store.get('bitcoin').lastSeen, 0)
|
||||
})
|
||||
|
||||
it('should preserve an existing followerCount when recording a post', async () => {
|
||||
const adapters = makeAdapters()
|
||||
adapters.topicSummaryDb.store.set('bitcoin', {
|
||||
room: 'bitcoin',
|
||||
postCount: 0,
|
||||
lastHeight: 0,
|
||||
lastSeen: 0,
|
||||
followerCount: 4
|
||||
})
|
||||
|
||||
await recordTopicPost(adapters, 'bitcoin', 600100, 1700000000000)
|
||||
|
||||
assert.equal(adapters.topicSummaryDb.store.get('bitcoin').followerCount, 4)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -45,13 +45,13 @@ function makeAdapters () {
|
||||
}
|
||||
}
|
||||
|
||||
async function processTopicMessage (adapters, { txid, room, addr, height, text }) {
|
||||
async function processTopicMessage (adapters, { txid, room, addr, height, text, seen = 1 }) {
|
||||
const prefix = Buffer.from('6d0c', 'hex')
|
||||
await handleTopicMessage({
|
||||
adapters,
|
||||
txid,
|
||||
signerAddr: addr,
|
||||
seen: 1,
|
||||
seen,
|
||||
blockHeight: height,
|
||||
decoded: {
|
||||
action: 'topic-message',
|
||||
@@ -76,7 +76,9 @@ describe('#handleTopicMessage topic indexes', () => {
|
||||
assert.deepEqual(adapters.topicSummaryDb.store.get('bitcoin'), {
|
||||
room: 'bitcoin',
|
||||
postCount: 1,
|
||||
lastHeight: 600100
|
||||
lastHeight: 600100,
|
||||
lastSeen: 1,
|
||||
followerCount: 0
|
||||
})
|
||||
assert.deepEqual(adapters.topicRecencyDb.store.get(topicRecencyKey(600100, 'bitcoin')), {
|
||||
room: 'bitcoin',
|
||||
@@ -84,6 +86,21 @@ describe('#handleTopicMessage topic indexes', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should record the post seen time as lastSeen', async () => {
|
||||
const adapters = makeAdapters()
|
||||
|
||||
await processTopicMessage(adapters, {
|
||||
txid: 'topic-a1',
|
||||
room: 'bitcoin',
|
||||
addr: 'bitcoincash:qaddr-a',
|
||||
height: 600100,
|
||||
text: 'hello',
|
||||
seen: 1700000000000
|
||||
})
|
||||
|
||||
assert.equal(adapters.topicSummaryDb.store.get('bitcoin').lastSeen, 1700000000000)
|
||||
})
|
||||
|
||||
it('should accumulate postCount and keep the newest height', async () => {
|
||||
const adapters = makeAdapters()
|
||||
|
||||
@@ -93,7 +110,9 @@ describe('#handleTopicMessage topic indexes', () => {
|
||||
assert.deepEqual(adapters.topicSummaryDb.store.get('bitcoin'), {
|
||||
room: 'bitcoin',
|
||||
postCount: 2,
|
||||
lastHeight: 600200
|
||||
lastHeight: 600200,
|
||||
lastSeen: 1,
|
||||
followerCount: 0
|
||||
})
|
||||
assert.deepEqual(adapters.topicRecencyDb.store.get(topicRecencyKey(600200, 'bitcoin')), {
|
||||
room: 'bitcoin',
|
||||
@@ -111,7 +130,9 @@ describe('#handleTopicMessage topic indexes', () => {
|
||||
assert.deepEqual(adapters.topicSummaryDb.store.get('bitcoin'), {
|
||||
room: 'bitcoin',
|
||||
postCount: 2,
|
||||
lastHeight: 600200
|
||||
lastHeight: 600200,
|
||||
lastSeen: 1,
|
||||
followerCount: 0
|
||||
})
|
||||
assert.deepEqual(adapters.topicRecencyDb.store.get(topicRecencyKey(600200, 'bitcoin')), {
|
||||
room: 'bitcoin',
|
||||
@@ -129,7 +150,9 @@ describe('#handleTopicMessage topic indexes', () => {
|
||||
assert.deepEqual(adapters.topicSummaryDb.store.get('bitcoin'), {
|
||||
room: 'bitcoin',
|
||||
postCount: 1,
|
||||
lastHeight: 600300
|
||||
lastHeight: 600300,
|
||||
lastSeen: 1,
|
||||
followerCount: 0
|
||||
})
|
||||
assert.equal(adapters.topicRecencyDb.store.size, 1)
|
||||
})
|
||||
@@ -173,7 +196,9 @@ describe('#handleTopicMessage topic indexes', () => {
|
||||
assert.deepEqual(adapters.topicSummaryDb.store.get(room), {
|
||||
room,
|
||||
postCount: 1,
|
||||
lastHeight: 600100
|
||||
lastHeight: 600100,
|
||||
lastSeen: 1,
|
||||
followerCount: 0
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user