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:
Chris Troutner
2026-09-18 05:46:58 -07:00
parent a599b34e38
commit 8da0f09f46
18 changed files with 727 additions and 81 deletions
+77 -5
View File
@@ -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({
+106
View File
@@ -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>)$/,
+17 -11
View File
@@ -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,
+15 -3
View File
@@ -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 })
})
})
+81 -8
View File
@@ -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
})
})