mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo.git
synced 2026-09-21 16:52:01 -07:00
Implement topic recency ordering and pagination
- Indexer maintains topicSummaries and topicRecency for topic messages and follows, idempotently, moving each room's recency record to its newest post. - DB adds the two index stores and serves GET /topics from them with limit/offset pagination, without iterating the rooms store. Adds util/room/backfill-topic-indexes.js to build the indexes idempotently. - Client topics page loads 50 per page with Previous/Next. By coder.
This commit is contained in:
@@ -361,13 +361,18 @@ function makeMemoDb () {
|
|||||||
async getMuteState (muterAddr, muteeAddr) {
|
async getMuteState (muterAddr, muteeAddr) {
|
||||||
return muteState[`${muterAddr}:${muteeAddr}`] || false
|
return muteState[`${muterAddr}:${muteeAddr}`] || false
|
||||||
},
|
},
|
||||||
async getTopics () {
|
async getTopics ({ limit = 50, offset = 0 } = {}) {
|
||||||
const list = []
|
const list = []
|
||||||
for (const [room, postCount] of topicCounts.entries()) {
|
for (const [room, postCount] of topicCounts.entries()) {
|
||||||
list.push({ room, postCount })
|
list.push({ room, postCount })
|
||||||
}
|
}
|
||||||
list.sort((a, b) => a.room.localeCompare(b.room))
|
list.sort((a, b) => a.room.localeCompare(b.room))
|
||||||
return { topics: list }
|
const total = list.length
|
||||||
|
const page = list.slice(offset, offset + limit)
|
||||||
|
return {
|
||||||
|
topics: page,
|
||||||
|
pagination: { limit, offset, total, hasMore: offset + page.length < total }
|
||||||
|
}
|
||||||
},
|
},
|
||||||
async getTopicPosts (room, { limit = 50, offset = 0, viewer = null } = {}) {
|
async getTopicPosts (room, { limit = 50, offset = 0, viewer = null } = {}) {
|
||||||
let all = topicPosts[room] || []
|
let all = topicPosts[room] || []
|
||||||
@@ -2141,6 +2146,54 @@ const handlers = [
|
|||||||
world.currentPath = TopicDiscoveryPage.TOPICS_PATH
|
world.currentPath = TopicDiscoveryPage.TOPICS_PATH
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'open topics page at offset',
|
||||||
|
pattern: /^I open the topics page at offset (<[A-Za-z0-9_]+>)$/,
|
||||||
|
async run (m, example, world) {
|
||||||
|
const offset = parseInt(resolveParam(m[1], example), 10)
|
||||||
|
await world.topicDiscoveryPage.load({ limit: 50, offset })
|
||||||
|
world.currentPath = TopicDiscoveryPage.TOPICS_PATH
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'API serves N topics',
|
||||||
|
pattern: /^the psf-memo-db API serves (<[A-Za-z0-9_]+>) topics$/,
|
||||||
|
run (m, example, world) {
|
||||||
|
const count = parseInt(resolveParam(m[1], example), 10)
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
world.memoDb.addTopic(`topic-${String(i + 1).padStart(3, '0')}`, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'topics page shows N topics',
|
||||||
|
pattern: /^the topics page shows (<[A-Za-z0-9_]+>) topics$/,
|
||||||
|
run (m, example, world) {
|
||||||
|
const expected = parseInt(resolveParam(m[1], example), 10)
|
||||||
|
const actual = world.topicDiscoveryPage.topics.length
|
||||||
|
if (actual !== expected) {
|
||||||
|
throw new Error(`Expected ${expected} topics on the topics page, got ${actual}.`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'topics page can load more topics',
|
||||||
|
pattern: /^the topics page can load more topics$/,
|
||||||
|
run (m, example, world) {
|
||||||
|
if (!world.topicDiscoveryPage.canLoadMore()) {
|
||||||
|
throw new Error('Expected the topics page to have more topics, but pagination says there are none.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'topics page has no more topics',
|
||||||
|
pattern: /^the topics page has no more topics$/,
|
||||||
|
run (m, example, world) {
|
||||||
|
if (world.topicDiscoveryPage.canLoadMore()) {
|
||||||
|
throw new Error('Expected the topics page to have no more topics, but pagination says there are more.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'topics page shows topic count',
|
name: 'topics page shows topic count',
|
||||||
pattern: /^the topics page shows the topic (<topic>) with (<count>) posts$/,
|
pattern: /^the topics page shows the topic (<topic>) with (<count>) posts$/,
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
/*
|
/*
|
||||||
Display the list of Memo topics served by psf-memo-db.
|
Display a page of Memo topics served by psf-memo-db.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Global npm libraries
|
// Global npm libraries
|
||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import { Container, Row, Col, Spinner, ListGroup } from 'react-bootstrap'
|
import { Container, Row, Col, Spinner, ListGroup, Button } from 'react-bootstrap'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
// Local libraries
|
// Local libraries
|
||||||
@@ -12,11 +12,15 @@ import MemoDb from '../../../services/memo-db'
|
|||||||
import TopicDiscoveryPage from '../../../services/topic-discovery-page'
|
import TopicDiscoveryPage from '../../../services/topic-discovery-page'
|
||||||
import '../../../App.css'
|
import '../../../App.css'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 50
|
||||||
|
|
||||||
function Topics (props) {
|
function Topics (props) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
const [topics, setTopics] = useState([])
|
const [topics, setTopics] = useState([])
|
||||||
|
const [pagination, setPagination] = useState(null)
|
||||||
|
const [offset, setOffset] = useState(0)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadTopics = async () => {
|
const loadTopics = async () => {
|
||||||
@@ -26,23 +30,36 @@ function Topics (props) {
|
|||||||
try {
|
try {
|
||||||
const memoDb = new MemoDb()
|
const memoDb = new MemoDb()
|
||||||
const page = new TopicDiscoveryPage({ memoDb, navigate })
|
const page = new TopicDiscoveryPage({ memoDb, navigate })
|
||||||
const result = await page.load()
|
const result = await page.load({ limit: PAGE_SIZE, offset })
|
||||||
setTopics(result.topics || [])
|
setTopics(result.topics || [])
|
||||||
|
setPagination(result.pagination || null)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message || 'Failed to load topics')
|
setError(err.message || 'Failed to load topics')
|
||||||
setTopics([])
|
setTopics([])
|
||||||
|
setPagination(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
loadTopics()
|
loadTopics()
|
||||||
}, [navigate])
|
}, [navigate, offset])
|
||||||
|
|
||||||
const handleClick = (room) => {
|
const handleClick = (room) => {
|
||||||
navigate(TopicDiscoveryPage.topicFeedPath(room))
|
navigate(TopicDiscoveryPage.topicFeedPath(room))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const canGoBack = offset > 0
|
||||||
|
const canGoNext = pagination?.hasMore ?? false
|
||||||
|
|
||||||
|
const handlePrevious = () => {
|
||||||
|
setOffset((prev) => Math.max(0, prev - PAGE_SIZE))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
setOffset((prev) => prev + PAGE_SIZE)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container className='topics-page'>
|
<Container className='topics-page'>
|
||||||
<Row className='justify-content-center'>
|
<Row className='justify-content-center'>
|
||||||
@@ -50,6 +67,13 @@ function Topics (props) {
|
|||||||
<header className='topics-heading'>
|
<header className='topics-heading'>
|
||||||
<h1>Topics</h1>
|
<h1>Topics</h1>
|
||||||
<p>Discover Memo conversations organized by topic.</p>
|
<p>Discover Memo conversations organized by topic.</p>
|
||||||
|
|
||||||
|
{pagination && topics.length > 0 && (
|
||||||
|
<span className='topics-count'>
|
||||||
|
Showing {pagination.offset + 1}–
|
||||||
|
{pagination.offset + topics.length} of {pagination.total}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
@@ -84,6 +108,26 @@ function Topics (props) {
|
|||||||
))}
|
))}
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && (pagination || offset > 0) && (
|
||||||
|
<div className='topics-pagination mt-3'>
|
||||||
|
<Button
|
||||||
|
variant='outline-dark'
|
||||||
|
onClick={handlePrevious}
|
||||||
|
disabled={!canGoBack}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant='outline-dark'
|
||||||
|
onClick={handleNext}
|
||||||
|
disabled={!canGoNext}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
@@ -44,8 +44,8 @@ class MemoDb {
|
|||||||
return this._getList(`/mute/muted/${encodeURIComponent(muterAddr)}`, 'getMuted', 'muted')
|
return this._getList(`/mute/muted/${encodeURIComponent(muterAddr)}`, 'getMuted', 'muted')
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTopics () {
|
async getTopics ({ limit = 50, offset = 0 } = {}) {
|
||||||
return this.getRecent('/topics', 'getTopics', {})
|
return this.getRecent('/topics', 'getTopics', { limit, offset })
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTopicPosts (room, { limit = 50, offset = 0, viewer = null } = {}) {
|
async getTopicPosts (room, { limit = 50, offset = 0, viewer = null } = {}) {
|
||||||
|
|||||||
@@ -1,29 +1,23 @@
|
|||||||
/*
|
/*
|
||||||
Topic Discovery Page behavior: load and display the list of Memo topics.
|
Topic Discovery Page behavior: load and display a page of Memo topics.
|
||||||
|
|
||||||
This is the testable controller behind the React "Topics" page. It wraps
|
This is the testable controller behind the React "Topics" page. It wraps
|
||||||
the MemoDb client and exposes the loaded topics so the view can render each
|
the MemoDb client and exposes the loaded topics and pagination so the view
|
||||||
topic name and post count.
|
can render each topic name and post count and move between pages.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
const PaginatedPage = require('./paginated-page')
|
||||||
|
|
||||||
const TOPICS_PATH = '/topics'
|
const TOPICS_PATH = '/topics'
|
||||||
|
|
||||||
class TopicDiscoveryPage {
|
class TopicDiscoveryPage extends PaginatedPage {
|
||||||
constructor (deps = {}) {
|
constructor (deps = {}) {
|
||||||
this.memoDb = deps.memoDb || null
|
super(deps, {
|
||||||
|
listField: 'topics',
|
||||||
|
loadMethod: 'getTopics',
|
||||||
|
errorMessage: 'Topic discovery page requires a memo db client.'
|
||||||
|
})
|
||||||
this.navigate = deps.navigate || (() => {})
|
this.navigate = deps.navigate || (() => {})
|
||||||
this.topics = []
|
|
||||||
}
|
|
||||||
|
|
||||||
async load () {
|
|
||||||
if (!this.memoDb) {
|
|
||||||
throw new Error('Topic discovery page requires a memo db client.')
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await this.memoDb.getTopics()
|
|
||||||
this.topics = data.topics || []
|
|
||||||
|
|
||||||
return { topics: this.topics }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getTopic (room) {
|
getTopic (room) {
|
||||||
|
|||||||
@@ -8,10 +8,13 @@ const test = require('node:test')
|
|||||||
const assert = require('node:assert/strict')
|
const assert = require('node:assert/strict')
|
||||||
const TopicDiscoveryPage = require('../../src/services/topic-discovery-page')
|
const TopicDiscoveryPage = require('../../src/services/topic-discovery-page')
|
||||||
|
|
||||||
function makeMemoDb (topics) {
|
function makeMemoDb ({ topics = [], pagination = null } = {}) {
|
||||||
|
const calls = []
|
||||||
return {
|
return {
|
||||||
async getTopics () {
|
calls,
|
||||||
return { topics }
|
async getTopics (opts) {
|
||||||
|
calls.push(opts)
|
||||||
|
return { topics, pagination }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -21,13 +24,56 @@ test('load returns topics with post counts', async () => {
|
|||||||
{ room: 'bitcoin', postCount: 2 },
|
{ room: 'bitcoin', postCount: 2 },
|
||||||
{ room: 'cash', postCount: 1 }
|
{ room: 'cash', postCount: 1 }
|
||||||
]
|
]
|
||||||
const page = new TopicDiscoveryPage({ memoDb: makeMemoDb(topics) })
|
const page = new TopicDiscoveryPage({ memoDb: makeMemoDb({ topics }) })
|
||||||
|
|
||||||
const result = await page.load()
|
const result = await page.load()
|
||||||
|
|
||||||
assert.deepEqual(result.topics, topics)
|
assert.deepEqual(result.topics, topics)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('load defaults to a page of 50 from offset 0', async () => {
|
||||||
|
const memoDb = makeMemoDb({ topics: [] })
|
||||||
|
const page = new TopicDiscoveryPage({ memoDb })
|
||||||
|
|
||||||
|
await page.load()
|
||||||
|
|
||||||
|
assert.deepEqual(memoDb.calls[0], { limit: 50, offset: 0 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('load forwards limit and offset and stores pagination', async () => {
|
||||||
|
const memoDb = makeMemoDb({
|
||||||
|
topics: [{ room: 'bitcoin', postCount: 2 }],
|
||||||
|
pagination: { limit: 50, offset: 50, total: 60, hasMore: false }
|
||||||
|
})
|
||||||
|
const page = new TopicDiscoveryPage({ memoDb })
|
||||||
|
|
||||||
|
const result = await page.load({ limit: 50, offset: 50 })
|
||||||
|
|
||||||
|
assert.deepEqual(memoDb.calls[0], { limit: 50, offset: 50 })
|
||||||
|
assert.deepEqual(result.pagination, { limit: 50, offset: 50, total: 60, hasMore: false })
|
||||||
|
assert.equal(page.pagination.offset, 50)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('canLoadMore reflects the pagination hasMore flag', async () => {
|
||||||
|
const more = new TopicDiscoveryPage({
|
||||||
|
memoDb: makeMemoDb({
|
||||||
|
topics: [{ room: 'a', postCount: 0 }],
|
||||||
|
pagination: { limit: 50, offset: 0, total: 60, hasMore: true }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
await more.load()
|
||||||
|
assert.equal(more.canLoadMore(), true)
|
||||||
|
|
||||||
|
const last = new TopicDiscoveryPage({
|
||||||
|
memoDb: makeMemoDb({
|
||||||
|
topics: [{ room: 'a', postCount: 0 }],
|
||||||
|
pagination: { limit: 50, offset: 50, total: 60, hasMore: false }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
await last.load()
|
||||||
|
assert.equal(last.canLoadMore(), false)
|
||||||
|
})
|
||||||
|
|
||||||
test('load throws when no memo db client is provided', async () => {
|
test('load throws when no memo db client is provided', async () => {
|
||||||
const page = new TopicDiscoveryPage({})
|
const page = new TopicDiscoveryPage({})
|
||||||
|
|
||||||
@@ -39,7 +85,7 @@ test('load throws when no memo db client is provided', async () => {
|
|||||||
|
|
||||||
test('stores the provided navigate function', () => {
|
test('stores the provided navigate function', () => {
|
||||||
const navigate = () => {}
|
const navigate = () => {}
|
||||||
const page = new TopicDiscoveryPage({ memoDb: makeMemoDb([]), navigate })
|
const page = new TopicDiscoveryPage({ memoDb: makeMemoDb({ topics: [] }), navigate })
|
||||||
|
|
||||||
assert.equal(page.navigate, navigate)
|
assert.equal(page.navigate, navigate)
|
||||||
})
|
})
|
||||||
@@ -49,7 +95,7 @@ test('getTopic returns the matching topic', async () => {
|
|||||||
{ room: 'bitcoin', postCount: 2 },
|
{ room: 'bitcoin', postCount: 2 },
|
||||||
{ room: 'cash', postCount: 1 }
|
{ room: 'cash', postCount: 1 }
|
||||||
]
|
]
|
||||||
const page = new TopicDiscoveryPage({ memoDb: makeMemoDb(topics) })
|
const page = new TopicDiscoveryPage({ memoDb: makeMemoDb({ topics }) })
|
||||||
|
|
||||||
await page.load()
|
await page.load()
|
||||||
|
|
||||||
@@ -57,7 +103,7 @@ test('getTopic returns the matching topic', async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('getTopic returns null when the topic is not loaded', async () => {
|
test('getTopic returns null when the topic is not loaded', async () => {
|
||||||
const page = new TopicDiscoveryPage({ memoDb: makeMemoDb([]) })
|
const page = new TopicDiscoveryPage({ memoDb: makeMemoDb({ topics: [] }) })
|
||||||
|
|
||||||
await page.load()
|
await page.load()
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import GetPoll from '../../src/use-cases/get-poll.js'
|
|||||||
import GetPollOptions from '../../src/use-cases/get-poll-options.js'
|
import GetPollOptions from '../../src/use-cases/get-poll-options.js'
|
||||||
import GetPollVotes from '../../src/use-cases/get-poll-votes.js'
|
import GetPollVotes from '../../src/use-cases/get-poll-votes.js'
|
||||||
import { repairTxidEncoding } from '../../src/lib/repair-txid-encoding.js'
|
import { repairTxidEncoding } from '../../src/lib/repair-txid-encoding.js'
|
||||||
|
import { backfillTopicIndexes, topicRecencyKey } from '../../src/lib/backfill-topic-indexes.js'
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
const tmpDir = path.resolve(__dirname, '..', '..', 'tmp', 'acceptance')
|
const tmpDir = path.resolve(__dirname, '..', '..', 'tmp', 'acceptance')
|
||||||
@@ -97,12 +98,16 @@ async function createWorld () {
|
|||||||
const postsGetCounter = { calls: 0 }
|
const postsGetCounter = { calls: 0 }
|
||||||
const likesIteratorCounter = { calls: 0 }
|
const likesIteratorCounter = { calls: 0 }
|
||||||
const postLikesIteratorCounter = { calls: 0 }
|
const postLikesIteratorCounter = { calls: 0 }
|
||||||
|
const roomsIteratorCounter = { calls: 0, entries: 0 }
|
||||||
|
const topicRecencyIteratorCounter = { calls: 0, entries: 0 }
|
||||||
wrapIterator(adapters.level.postHeightsDb, postHeightsIteratorCounter)
|
wrapIterator(adapters.level.postHeightsDb, postHeightsIteratorCounter)
|
||||||
wrapIterator(adapters.level.addrPostHeightsDb, addrPostHeightsIteratorCounter)
|
wrapIterator(adapters.level.addrPostHeightsDb, addrPostHeightsIteratorCounter)
|
||||||
wrapIterator(adapters.level.postChildrenDb, postChildrenIteratorCounter)
|
wrapIterator(adapters.level.postChildrenDb, postChildrenIteratorCounter)
|
||||||
wrapGet(adapters.level.postsDb, postsGetCounter)
|
wrapGet(adapters.level.postsDb, postsGetCounter)
|
||||||
wrapIterator(adapters.level.likesDb, likesIteratorCounter)
|
wrapIterator(adapters.level.likesDb, likesIteratorCounter)
|
||||||
wrapIterator(adapters.level.postLikesDb, postLikesIteratorCounter)
|
wrapIterator(adapters.level.postLikesDb, postLikesIteratorCounter)
|
||||||
|
wrapIterator(adapters.level.roomsDb, roomsIteratorCounter)
|
||||||
|
wrapIterator(adapters.level.topicRecencyDb, topicRecencyIteratorCounter)
|
||||||
|
|
||||||
const listRecentPosts = new ListRecentPosts({ adapters })
|
const listRecentPosts = new ListRecentPosts({ adapters })
|
||||||
const listPostsByAddr = new ListPostsByAddr({ adapters })
|
const listPostsByAddr = new ListPostsByAddr({ adapters })
|
||||||
@@ -145,6 +150,8 @@ async function createWorld () {
|
|||||||
postsGetCounter,
|
postsGetCounter,
|
||||||
likesIteratorCounter,
|
likesIteratorCounter,
|
||||||
postLikesIteratorCounter,
|
postLikesIteratorCounter,
|
||||||
|
roomsIteratorCounter,
|
||||||
|
topicRecencyIteratorCounter,
|
||||||
getLastResponse: () => lastResponse,
|
getLastResponse: () => lastResponse,
|
||||||
setLastResponse: (resp) => { lastResponse = resp },
|
setLastResponse: (resp) => { lastResponse = resp },
|
||||||
close: async () => {
|
close: async () => {
|
||||||
@@ -195,6 +202,16 @@ async function loadFixture (world, name) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (name === 'topic-indexes') {
|
||||||
|
await loadTopicIndexes(world)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'rooms-with-topics-and-follows') {
|
||||||
|
await loadRoomsWithTopicsAndFollows(world)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (name === 'topic-follows') {
|
if (name === 'topic-follows') {
|
||||||
await loadTopicFollows(world)
|
await loadTopicFollows(world)
|
||||||
return
|
return
|
||||||
@@ -502,6 +519,56 @@ async function loadTopicsWithPosts (world) {
|
|||||||
for (const [txid, post] of Object.entries(posts)) {
|
for (const [txid, post] of Object.entries(posts)) {
|
||||||
await world.adapters.level.postsDb.put(txid, post)
|
await world.adapters.level.postsDb.put(txid, post)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Derived topic indexes (see topic-read.feature).
|
||||||
|
const summaries = [
|
||||||
|
{ room: 'bitcoin', postCount: 2, lastHeight: 300 },
|
||||||
|
{ room: 'cash', postCount: 1, lastHeight: 250 },
|
||||||
|
{ room: 'dev', postCount: 1, lastHeight: 400 },
|
||||||
|
{ room: 'lone', postCount: 0, lastHeight: 0 }
|
||||||
|
]
|
||||||
|
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 "topic-indexes" from topic-pagination.feature: indexes only.
|
||||||
|
async function loadTopicIndexes (world) {
|
||||||
|
const summaries = [
|
||||||
|
{ room: 'memo', postCount: 5, lastHeight: 600500 },
|
||||||
|
{ room: 'cash', postCount: 2, lastHeight: 600400 },
|
||||||
|
{ room: 'dance', postCount: 3, lastHeight: 600400 },
|
||||||
|
{ room: 'anime', postCount: 1, lastHeight: 600300 },
|
||||||
|
{ room: 'lone', postCount: 0, lastHeight: 0 },
|
||||||
|
{ room: 'quiet', postCount: 0, lastHeight: 0 }
|
||||||
|
]
|
||||||
|
|
||||||
|
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-topics-and-follows" from backfill-topic-indexes.feature.
|
||||||
|
async function loadRoomsWithTopicsAndFollows (world) {
|
||||||
|
const entries = [
|
||||||
|
{ key: 'bitcoin:post-100', room: 'bitcoin', txid: 'post-100', type: 'post', blockHeight: 600100 },
|
||||||
|
{ key: 'bitcoin:post-200', room: 'bitcoin', txid: 'post-200', type: 'post', blockHeight: 600200 },
|
||||||
|
{ key: 'bitcoin:addr-f', room: 'bitcoin', addr: 'addr-f', type: 'follow', unfollow: false },
|
||||||
|
{ key: 'cash:post-250', room: 'cash', txid: 'post-250', type: 'post', blockHeight: 600250 },
|
||||||
|
{ key: 'lone:addr-f', room: 'lone', addr: 'addr-f', type: 'follow', unfollow: false }
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
await world.adapters.level.roomsDb.put(entry.key, entry)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadTopicFollows (world) {
|
async function loadTopicFollows (world) {
|
||||||
@@ -1119,7 +1186,7 @@ const handlers = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'response lists topics in order',
|
name: 'response lists topics in order',
|
||||||
pattern: /^the response lists topics in order (<expected_order>)$/,
|
pattern: /^the response lists topics in order (<[A-Za-z0-9_]+>)$/,
|
||||||
run (m, example, world) {
|
run (m, example, world) {
|
||||||
const expected = resolveParam(m[1], example).split(',').map((s) => s.trim())
|
const expected = resolveParam(m[1], example).split(',').map((s) => s.trim())
|
||||||
const actual = world.getLastResponse().topics.map((t) => t.room)
|
const actual = world.getLastResponse().topics.map((t) => t.room)
|
||||||
@@ -1163,6 +1230,157 @@ const handlers = [
|
|||||||
await loadFixture(world, m[1])
|
await loadFixture(world, m[1])
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'db instance with rooms, posts, and topic index stores',
|
||||||
|
pattern: /^a psf-memo-db instance with rooms, posts, topicSummaries, and topicRecency stores$/,
|
||||||
|
async run () {
|
||||||
|
// World is already created with all stores.
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'load fixture into rooms, posts, and topic index stores',
|
||||||
|
pattern: /^the fixture "(.+)" is loaded into the rooms, posts, topicSummaries, and topicRecency stores$/,
|
||||||
|
async run (m, example, world) {
|
||||||
|
await loadFixture(world, m[1])
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'db instance with topic index stores',
|
||||||
|
pattern: /^a psf-memo-db instance with topicSummaries and topicRecency stores$/,
|
||||||
|
async run () {
|
||||||
|
// World is already created with both topic index stores.
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'load fixture into topic index stores',
|
||||||
|
pattern: /^the fixture "(.+)" is loaded into the topic index stores$/,
|
||||||
|
async run (m, example, world) {
|
||||||
|
await loadFixture(world, m[1])
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'db instance with rooms and topic index stores',
|
||||||
|
pattern: /^a psf-memo-db instance with rooms, topicSummaries, and topicRecency stores$/,
|
||||||
|
async run () {
|
||||||
|
// World is already created with all stores.
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'request topics with limit and offset',
|
||||||
|
pattern: /^the client requests \/topics with limit (<limit>) and offset (<offset>)$/,
|
||||||
|
async run (m, example, world) {
|
||||||
|
const limit = parseInt(resolveParam(m[1], example), 10)
|
||||||
|
const offset = parseInt(resolveParam(m[2], example), 10)
|
||||||
|
const resp = await world.listTopics.execute({ limit, offset })
|
||||||
|
world.setLastResponse(resp)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'response contains quoted topic with post count',
|
||||||
|
pattern: /^the response contains the topic "(<topic>)" with post count (<postCount>)$/,
|
||||||
|
run (m, example, world) {
|
||||||
|
const topic = resolveParam(m[1], example)
|
||||||
|
const expectedCount = 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.postCount !== expectedCount) {
|
||||||
|
throw new Error(`Expected post count ${expectedCount} for ${topic}, got ${found.postCount}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'response pagination shows total and hasMore',
|
||||||
|
pattern: /^the response pagination shows total (<total>) and hasMore (<hasMore>)$/,
|
||||||
|
run (m, example, world) {
|
||||||
|
const expectedTotal = parseInt(resolveParam(m[1], example), 10)
|
||||||
|
const expectedHasMore = resolveParam(m[2], example) === 'true'
|
||||||
|
const pagination = world.getLastResponse().pagination
|
||||||
|
if (!pagination) {
|
||||||
|
throw new Error('Response has no pagination metadata')
|
||||||
|
}
|
||||||
|
if (pagination.total !== expectedTotal) {
|
||||||
|
throw new Error(`Expected total ${expectedTotal}, got ${pagination.total}`)
|
||||||
|
}
|
||||||
|
if (pagination.hasMore !== expectedHasMore) {
|
||||||
|
throw new Error(`Expected hasMore ${expectedHasMore}, got ${pagination.hasMore}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'topicRecency store read count',
|
||||||
|
pattern: /^the topicRecency store was read exactly (<read_count>) records$/,
|
||||||
|
run (m, example, world) {
|
||||||
|
const expected = parseInt(resolveParam(m[1], example), 10)
|
||||||
|
const actual = world.topicRecencyIteratorCounter.entries || 0
|
||||||
|
if (actual !== expected) {
|
||||||
|
throw new Error(`Expected topicRecency to be read ${expected} times, got ${actual}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'rooms store was not iterated',
|
||||||
|
pattern: /^the rooms store was not iterated$/,
|
||||||
|
run (m, example, world) {
|
||||||
|
if (world.roomsIteratorCounter.calls !== 0) {
|
||||||
|
throw new Error(`Expected rooms store not to be iterated, got ${world.roomsIteratorCounter.calls} call(s)`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'run topic backfill utility',
|
||||||
|
pattern: /^the topic backfill utility is run$/,
|
||||||
|
async run (m, example, world) {
|
||||||
|
await backfillTopicIndexes({
|
||||||
|
roomsDb: world.adapters.level.roomsDb,
|
||||||
|
topicSummariesDb: world.adapters.level.topicSummariesDb,
|
||||||
|
topicRecencyDb: world.adapters.level.topicRecencyDb
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'run topic backfill utility again',
|
||||||
|
pattern: /^the topic backfill utility is run again$/,
|
||||||
|
async run (m, example, world) {
|
||||||
|
await backfillTopicIndexes({
|
||||||
|
roomsDb: world.adapters.level.roomsDb,
|
||||||
|
topicSummariesDb: world.adapters.level.topicSummariesDb,
|
||||||
|
topicRecencyDb: world.adapters.level.topicRecencyDb
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'topicSummaries contains room with postCount and lastHeight',
|
||||||
|
pattern: /^the topicSummaries store contains the room "(<room>)" with postCount (<postCount>) and lastHeight (<[A-Za-z0-9_]+>)$/,
|
||||||
|
async run (m, example, world) {
|
||||||
|
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.adapters.level.topicSummariesDb.get(room)
|
||||||
|
if (!record) {
|
||||||
|
throw new Error(`No topicSummaries record for ${room}`)
|
||||||
|
}
|
||||||
|
if (record.room !== room || record.postCount !== postCount || record.lastHeight !== lastHeight) {
|
||||||
|
throw new Error(`Expected ${room} postCount ${postCount} lastHeight ${lastHeight}, got ${JSON.stringify(record)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'topicRecency records room at height',
|
||||||
|
pattern: /^the topicRecency store records the room "(<room>)" at block height (<height>)$/,
|
||||||
|
async run (m, example, world) {
|
||||||
|
const room = resolveParam(m[1], example)
|
||||||
|
const height = parseInt(resolveParam(m[2], example), 10)
|
||||||
|
const record = await world.adapters.level.topicRecencyDb.get(topicRecencyKey(height, room))
|
||||||
|
if (!record) {
|
||||||
|
throw new Error(`No topicRecency record for ${room} at ${height}`)
|
||||||
|
}
|
||||||
|
if (record.room !== room || record.blockHeight !== height) {
|
||||||
|
throw new Error(`Expected topicRecency ${room} at ${height}, got ${JSON.stringify(record)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'request topic follow state',
|
name: 'request topic follow state',
|
||||||
pattern: /^the client requests the topic follow state for room (<[A-Za-z0-9_]+>) and address (<[A-Za-z0-9_]+>)$/,
|
pattern: /^the client requests the topic follow state for room (<[A-Za-z0-9_]+>) and address (<[A-Za-z0-9_]+>)$/,
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ class Adapters {
|
|||||||
this.topicQuery = new TopicQuery({
|
this.topicQuery = new TopicQuery({
|
||||||
roomsDb: level.roomsDb,
|
roomsDb: level.roomsDb,
|
||||||
postsDb: level.postsDb,
|
postsDb: level.postsDb,
|
||||||
|
topicSummariesDb: level.topicSummariesDb,
|
||||||
|
topicRecencyDb: level.topicRecencyDb,
|
||||||
muteQuery: this.muteQuery
|
muteQuery: this.muteQuery
|
||||||
})
|
})
|
||||||
this.pollQuery = new PollQuery({
|
this.pollQuery = new PollQuery({
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ const DB_NAMES = [
|
|||||||
'follows',
|
'follows',
|
||||||
'mutes',
|
'mutes',
|
||||||
'rooms',
|
'rooms',
|
||||||
|
'topicSummaries',
|
||||||
|
'topicRecency',
|
||||||
'processErrors',
|
'processErrors',
|
||||||
'ptxs',
|
'ptxs',
|
||||||
'polls',
|
'polls',
|
||||||
|
|||||||
@@ -5,8 +5,16 @@
|
|||||||
- Topic posts are keyed `${room}:${txid}` with type 'post'.
|
- Topic posts are keyed `${room}:${txid}` with type 'post'.
|
||||||
- Topic follows are keyed `${room}:${addr}` with type 'follow'.
|
- Topic follows are keyed `${room}:${addr}` with type 'follow'.
|
||||||
|
|
||||||
|
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 }.
|
||||||
|
- 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.
|
||||||
|
|
||||||
This adapter exposes:
|
This adapter exposes:
|
||||||
- listTopics() - distinct rooms with post counts
|
- listTopics() - ordered, paginated distinct rooms with counts
|
||||||
- getTopicPostTxids() - paginated txids for a room sorted by block height
|
- getTopicPostTxids() - paginated txids for a room sorted by block height
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -14,15 +22,23 @@ import { loadMutedAddrs, isMutedPost } from './lib/muted-posts.js'
|
|||||||
|
|
||||||
class TopicQuery {
|
class TopicQuery {
|
||||||
constructor (localConfig = {}) {
|
constructor (localConfig = {}) {
|
||||||
const { roomsDb, postsDb, muteQuery } = localConfig
|
const { roomsDb, postsDb, topicSummariesDb, topicRecencyDb, muteQuery } = localConfig
|
||||||
if (!roomsDb) {
|
if (!roomsDb) {
|
||||||
throw new Error('roomsDb required when instantiating TopicQuery adapter.')
|
throw new Error('roomsDb required when instantiating TopicQuery adapter.')
|
||||||
}
|
}
|
||||||
if (!postsDb) {
|
if (!postsDb) {
|
||||||
throw new Error('postsDb required when instantiating TopicQuery adapter.')
|
throw new Error('postsDb required when instantiating TopicQuery adapter.')
|
||||||
}
|
}
|
||||||
|
if (!topicSummariesDb) {
|
||||||
|
throw new Error('topicSummariesDb required when instantiating TopicQuery adapter.')
|
||||||
|
}
|
||||||
|
if (!topicRecencyDb) {
|
||||||
|
throw new Error('topicRecencyDb required when instantiating TopicQuery adapter.')
|
||||||
|
}
|
||||||
this.roomsDb = roomsDb
|
this.roomsDb = roomsDb
|
||||||
this.postsDb = postsDb
|
this.postsDb = postsDb
|
||||||
|
this.topicSummariesDb = topicSummariesDb
|
||||||
|
this.topicRecencyDb = topicRecencyDb
|
||||||
this.muteQuery = muteQuery || null
|
this.muteQuery = muteQuery || null
|
||||||
|
|
||||||
this.listTopics = this.listTopics.bind(this)
|
this.listTopics = this.listTopics.bind(this)
|
||||||
@@ -44,35 +60,52 @@ class TopicQuery {
|
|||||||
return parts[parts.length - 1]
|
return parts[parts.length - 1]
|
||||||
}
|
}
|
||||||
|
|
||||||
async listTopics () {
|
// The topicSummaries key is the room name; fall back to the key when the
|
||||||
const topics = new Map()
|
// stored value omits it.
|
||||||
|
summaryRoom (key, value) {
|
||||||
|
if (value && typeof value.room === 'string') return value.room
|
||||||
|
return String(key)
|
||||||
|
}
|
||||||
|
|
||||||
for await (const [key, value] of this.roomsDb.iterator()) {
|
// The topicRecency key is `${invertedHeight}:${room}`; the stored value
|
||||||
const room = this.roomFromKey(key, value)
|
// carries the room name, but fall back to the key for robustness.
|
||||||
if (!topics.has(room)) {
|
recencyRoom (key, value) {
|
||||||
topics.set(room, { postCount: 0, lastHeight: 0 })
|
if (value && typeof value.room === 'string') return value.room
|
||||||
}
|
const parts = String(key).split(':')
|
||||||
const topic = topics.get(room)
|
return parts.slice(1).join(':')
|
||||||
if (value?.type === 'post') {
|
}
|
||||||
topic.postCount++
|
|
||||||
const height = value?.blockHeight ?? 0
|
// Return a page of topics ordered by their most recent post, descending,
|
||||||
if (height > topic.lastHeight) {
|
// with rooms at the same height ordered by name ascending. Counts and the
|
||||||
topic.lastHeight = height
|
// 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()
|
||||||
|
for await (const [key, value] of this.topicSummariesDb.iterator()) {
|
||||||
|
postCounts.set(this.summaryRoom(key, value), value?.postCount ?? 0)
|
||||||
|
}
|
||||||
|
const total = postCounts.size
|
||||||
|
|
||||||
|
const recencyRooms = []
|
||||||
|
for await (const [key, value] of this.topicRecencyDb.iterator({ limit: offset + limit })) {
|
||||||
|
recencyRooms.push(this.recencyRoom(key, value))
|
||||||
}
|
}
|
||||||
|
|
||||||
return Array.from(topics.entries())
|
const pageRooms = recencyRooms.slice(offset, offset + limit)
|
||||||
.map(([room, { postCount, lastHeight }]) => ({ room, postCount, lastHeight }))
|
const topics = pageRooms.map((room) => ({
|
||||||
.sort((a, b) => {
|
room,
|
||||||
if (b.lastHeight !== a.lastHeight) {
|
postCount: postCounts.get(room) ?? 0
|
||||||
return b.lastHeight - a.lastHeight
|
}))
|
||||||
}
|
|
||||||
return a.room.localeCompare(b.room)
|
return {
|
||||||
})
|
topics,
|
||||||
// lastHeight is an internal ordering key; keep it out of the public
|
pagination: {
|
||||||
// API contract so the response exposes only room and postCount.
|
limit,
|
||||||
.map(({ room, postCount }) => ({ room, postCount }))
|
offset,
|
||||||
|
total,
|
||||||
|
hasMore: offset + topics.length < total
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTopicPostTxids (room, { limit, offset, viewerAddr = null }) {
|
async getTopicPostTxids (room, { limit, offset, viewerAddr = null }) {
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ export const ENTITY_CONFIG = [
|
|||||||
{ route: 'profilepic', dbProp: 'profilePicsDb', keyParam: 'addr', bodyIdField: 'addr', bodyDataField: 'profilePicData' },
|
{ route: 'profilepic', dbProp: 'profilePicsDb', keyParam: 'addr', bodyIdField: 'addr', bodyDataField: 'profilePicData' },
|
||||||
{ route: 'follow', dbProp: 'followsDb', keyParam: 'key', bodyIdField: 'key', bodyDataField: 'followData' },
|
{ route: 'follow', dbProp: 'followsDb', keyParam: 'key', bodyIdField: 'key', bodyDataField: 'followData' },
|
||||||
{ route: 'room', dbProp: 'roomsDb', keyParam: 'key', bodyIdField: 'key', bodyDataField: 'roomData' },
|
{ route: 'room', dbProp: 'roomsDb', keyParam: 'key', bodyIdField: 'key', bodyDataField: 'roomData' },
|
||||||
|
{ route: 'topicsummary', dbProp: 'topicSummariesDb', keyParam: 'key', bodyIdField: 'key', bodyDataField: 'topicSummaryData' },
|
||||||
|
{ route: 'topicrecency', dbProp: 'topicRecencyDb', keyParam: 'key', bodyIdField: 'key', bodyDataField: 'topicRecencyData' },
|
||||||
{ route: 'processerror', dbProp: 'processErrorsDb', keyParam: 'txid', bodyIdField: 'txid', bodyDataField: 'errorData' },
|
{ route: 'processerror', dbProp: 'processErrorsDb', keyParam: 'txid', bodyIdField: 'txid', bodyDataField: 'errorData' },
|
||||||
{ route: 'ptx', dbProp: 'ptxsDb', keyParam: 'txid', bodyIdField: 'txid', bodyDataField: 'ptxData' },
|
{ route: 'ptx', dbProp: 'ptxsDb', keyParam: 'txid', bodyIdField: 'txid', bodyDataField: 'ptxData' },
|
||||||
{ route: 'poll', dbProp: 'pollsDb', keyParam: 'txid', bodyIdField: 'txid', bodyDataField: 'pollData' },
|
{ route: 'poll', dbProp: 'pollsDb', keyParam: 'txid', bodyIdField: 'txid', bodyDataField: 'pollData' },
|
||||||
|
|||||||
@@ -32,7 +32,11 @@ class TopicsRESTControllerLib {
|
|||||||
* @apiName GetTopics
|
* @apiName GetTopics
|
||||||
* @apiGroup REST Topics
|
* @apiGroup REST Topics
|
||||||
*
|
*
|
||||||
* @apiDescription Returns all distinct Memo topics with their post counts.
|
* @apiDescription Returns a page of distinct Memo topics ordered by their
|
||||||
|
* most recent post, newest first. Supports limit and offset.
|
||||||
|
*
|
||||||
|
* @apiQuery {Number} [limit=100] Page size (max 100)
|
||||||
|
* @apiQuery {Number} [offset=0] Number of topics to skip after sorting
|
||||||
*
|
*
|
||||||
* @apiExample Example usage:
|
* @apiExample Example usage:
|
||||||
* curl -X GET "localhost:5021/topics"
|
* curl -X GET "localhost:5021/topics"
|
||||||
@@ -40,10 +44,12 @@ class TopicsRESTControllerLib {
|
|||||||
* @apiSuccess {Object[]} topics Array of topic objects
|
* @apiSuccess {Object[]} topics Array of topic objects
|
||||||
* @apiSuccess {String} topics.room Topic name
|
* @apiSuccess {String} topics.room Topic name
|
||||||
* @apiSuccess {Number} topics.postCount Number of posts in the topic
|
* @apiSuccess {Number} topics.postCount Number of posts in the topic
|
||||||
|
* @apiSuccess {Object} pagination Pagination metadata
|
||||||
*/
|
*/
|
||||||
async getTopics (ctx) {
|
async getTopics (ctx) {
|
||||||
try {
|
try {
|
||||||
ctx.body = await this.useCases.listTopics.execute()
|
const { limit, offset } = ctx.query
|
||||||
|
ctx.body = await this.useCases.listTopics.execute({ limit, offset })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.handleError(ctx, err)
|
this.handleError(ctx, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
/*
|
||||||
|
Library to build the topicSummaries and topicRecency indexes from an
|
||||||
|
existing rooms store.
|
||||||
|
|
||||||
|
The read side serves GET /topics from these two indexes:
|
||||||
|
- topicSummaries: one record per room keyed by room, value
|
||||||
|
{ room, postCount, lastHeight }.
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
The backfill recomputes both indexes from the rooms store, so running it
|
||||||
|
more than once is idempotent. Stale index records for rooms that no longer
|
||||||
|
have any room entry, or that moved to a new height, are removed.
|
||||||
|
|
||||||
|
The LevelDB handles are injected so the logic stays testable and free of
|
||||||
|
file-system concerns; the CLI wrapper in util/room opens the real stores.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const HEIGHT_PAD = 12
|
||||||
|
// Heights are inverted in the recency key so a plain ascending iterator yields
|
||||||
|
// rooms from most recent to least recent. Rooms at the same height still sort
|
||||||
|
// by room name ascending.
|
||||||
|
const MAX_HEIGHT = 999999999999
|
||||||
|
|
||||||
|
export function topicRecencyKey (blockHeight, room) {
|
||||||
|
const inverted = MAX_HEIGHT - (blockHeight ?? 0)
|
||||||
|
return `${String(inverted).padStart(HEIGHT_PAD, '0')}:${room}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectSummaries (roomsDb) {
|
||||||
|
const summaries = new Map()
|
||||||
|
|
||||||
|
for await (const [key, value] of roomsDb.iterator()) {
|
||||||
|
const room = (value && typeof value.room === 'string') ? value.room : String(key).split(':')[0]
|
||||||
|
if (!summaries.has(room)) {
|
||||||
|
summaries.set(room, { room, postCount: 0, lastHeight: 0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value?.type === 'post') {
|
||||||
|
const summary = summaries.get(room)
|
||||||
|
summary.postCount++
|
||||||
|
const height = value.blockHeight ?? 0
|
||||||
|
if (height > summary.lastHeight) summary.lastHeight = height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return summaries
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove index records that no longer have a desired key.
|
||||||
|
async function removeStale (db, desiredKeys) {
|
||||||
|
for await (const [key] of db.iterator()) {
|
||||||
|
if (!desiredKeys.has(key)) {
|
||||||
|
await db.del(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function backfillTopicIndexes ({ roomsDb, topicSummariesDb, topicRecencyDb }) {
|
||||||
|
const summaries = await collectSummaries(roomsDb)
|
||||||
|
const desiredRecencyKeys = new Set()
|
||||||
|
|
||||||
|
for (const summary of summaries.values()) {
|
||||||
|
await topicSummariesDb.put(summary.room, summary)
|
||||||
|
|
||||||
|
const key = topicRecencyKey(summary.lastHeight, summary.room)
|
||||||
|
desiredRecencyKeys.add(key)
|
||||||
|
await topicRecencyDb.put(key, { room: summary.room, blockHeight: summary.lastHeight })
|
||||||
|
}
|
||||||
|
|
||||||
|
await removeStale(topicRecencyDb, desiredRecencyKeys)
|
||||||
|
await removeStale(topicSummariesDb, new Set(summaries.keys()))
|
||||||
|
|
||||||
|
return { rooms: summaries.size }
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
/*
|
/*
|
||||||
Use case: list all distinct Memo topics with their post counts.
|
Use case: list a page of distinct Memo topics ordered by most recent post.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ListUseCase } from './lib/use-case.js'
|
import { ListUseCase } from './lib/use-case.js'
|
||||||
|
import { parseLimit, parseOffset } from './lib/pagination.js'
|
||||||
|
|
||||||
class ListTopics extends ListUseCase {
|
class ListTopics extends ListUseCase {
|
||||||
constructor (localConfig = {}) {
|
constructor (localConfig = {}) {
|
||||||
@@ -10,8 +11,9 @@ class ListTopics extends ListUseCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async execute (inObj = {}) {
|
async execute (inObj = {}) {
|
||||||
const topics = await this.adapters.topicQuery.listTopics()
|
const limit = parseLimit(inObj.limit)
|
||||||
return { topics }
|
const offset = parseOffset(inObj.offset)
|
||||||
|
return this.adapters.topicQuery.listTopics({ limit, offset })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,10 +47,19 @@ function makeRoomsDb (entries) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Empty index store; this suite exercises the follow read side, not listTopics.
|
||||||
|
function makeEmptyIndexDb () {
|
||||||
|
return {
|
||||||
|
async * iterator () {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function makeQuery (entries) {
|
function makeQuery (entries) {
|
||||||
return new TopicQuery({
|
return new TopicQuery({
|
||||||
roomsDb: makeRoomsDb(entries),
|
roomsDb: makeRoomsDb(entries),
|
||||||
postsDb: {}
|
postsDb: {},
|
||||||
|
topicSummariesDb: makeEmptyIndexDb(),
|
||||||
|
topicRecencyDb: makeEmptyIndexDb()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import test from 'node:test'
|
|||||||
|
|
||||||
import { seededRandom, forAll, intGen, txidGen } from './harness.js'
|
import { seededRandom, forAll, intGen, txidGen } from './harness.js'
|
||||||
import TopicQuery from '../../src/adapters/topic-query.js'
|
import TopicQuery from '../../src/adapters/topic-query.js'
|
||||||
|
import { topicRecencyKey } from '../../src/lib/backfill-topic-indexes.js'
|
||||||
|
|
||||||
const rng = seededRandom(20260828)
|
const rng = seededRandom(20260828)
|
||||||
|
|
||||||
@@ -45,10 +46,55 @@ function makeRoomsDb (entries) {
|
|||||||
function makeQuery (entries) {
|
function makeQuery (entries) {
|
||||||
return new TopicQuery({
|
return new TopicQuery({
|
||||||
roomsDb: makeRoomsDb(entries),
|
roomsDb: makeRoomsDb(entries),
|
||||||
postsDb: {}
|
postsDb: {},
|
||||||
|
topicSummariesDb: makeIndexDb([]),
|
||||||
|
topicRecencyDb: makeIndexDb([])
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// In-memory index store honoring the LevelDB `limit` option used by listTopics.
|
||||||
|
function makeIndexDb (records) {
|
||||||
|
const store = new Map(records)
|
||||||
|
return {
|
||||||
|
async * iterator (opts = {}) {
|
||||||
|
const keys = Array.from(store.keys()).sort()
|
||||||
|
const limit = opts.limit === undefined ? keys.length : opts.limit
|
||||||
|
for (const key of keys.slice(0, limit)) {
|
||||||
|
yield [key, store.get(key)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive topicSummaries and topicRecency records from rooms-store entries the
|
||||||
|
// way the indexer and backfill do.
|
||||||
|
function indexDbsFromEntries (entries) {
|
||||||
|
const summaries = new Map()
|
||||||
|
for (const e of entries) {
|
||||||
|
const room = e.value.room
|
||||||
|
if (!summaries.has(room)) summaries.set(room, { room, postCount: 0, lastHeight: 0 })
|
||||||
|
if (e.value.type === 'post') {
|
||||||
|
const summary = summaries.get(room)
|
||||||
|
summary.postCount++
|
||||||
|
const height = e.value.blockHeight ?? 0
|
||||||
|
if (height > summary.lastHeight) summary.lastHeight = height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const recency = []
|
||||||
|
for (const summary of summaries.values()) {
|
||||||
|
recency.push([
|
||||||
|
topicRecencyKey(summary.lastHeight, summary.room),
|
||||||
|
{ room: summary.room, blockHeight: summary.lastHeight }
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
topicSummariesDb: makeIndexDb(Array.from(summaries.entries())),
|
||||||
|
topicRecencyDb: makeIndexDb(recency)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// In-memory posts store exposing the LevelDB `get` contract used by the mute
|
// In-memory posts store exposing the LevelDB `get` contract used by the mute
|
||||||
// post lookup. Returns the post record or throws a LEVEL_NOT_FOUND-style error.
|
// post lookup. Returns the post record or throws a LEVEL_NOT_FOUND-style error.
|
||||||
function makePostsDb (entries) {
|
function makePostsDb (entries) {
|
||||||
@@ -112,14 +158,15 @@ function fixtureGen () {
|
|||||||
test('listTopics conserves post counts and returns rooms sorted by most recent post', async () => {
|
test('listTopics conserves post counts and returns rooms sorted by most recent post', async () => {
|
||||||
await forAll(
|
await forAll(
|
||||||
fixtureGen(),
|
fixtureGen(),
|
||||||
async ({ entries, rooms }) => {
|
async ({ entries, limit, offset }) => {
|
||||||
const query = makeQuery(entries)
|
const query = new TopicQuery({
|
||||||
const topics = await query.listTopics()
|
roomsDb: makeRoomsDb(entries),
|
||||||
|
postsDb: {},
|
||||||
|
...indexDbsFromEntries(entries)
|
||||||
|
})
|
||||||
|
const { topics, pagination } = await query.listTopics({ limit, offset })
|
||||||
|
|
||||||
const postEntries = entries.filter((e) => e.value.type === 'post')
|
const postEntries = entries.filter((e) => e.value.type === 'post')
|
||||||
const totalPosts = topics.reduce((sum, t) => sum + t.postCount, 0)
|
|
||||||
if (totalPosts !== postEntries.length) return false
|
|
||||||
|
|
||||||
const expectedTopics = [...new Set(entries.map((e) => e.value.room))]
|
const expectedTopics = [...new Set(entries.map((e) => e.value.room))]
|
||||||
.map((room) => {
|
.map((room) => {
|
||||||
const heights = entries
|
const heights = entries
|
||||||
@@ -131,7 +178,12 @@ test('listTopics conserves post counts and returns rooms sorted by most recent p
|
|||||||
if (b.lastHeight !== a.lastHeight) return b.lastHeight - a.lastHeight
|
if (b.lastHeight !== a.lastHeight) return b.lastHeight - a.lastHeight
|
||||||
return a.room.localeCompare(b.room)
|
return a.room.localeCompare(b.room)
|
||||||
})
|
})
|
||||||
if (JSON.stringify(topics.map((t) => t.room)) !== JSON.stringify(expectedTopics.map((t) => t.room))) return false
|
|
||||||
|
if (pagination.total !== expectedTopics.length) return false
|
||||||
|
|
||||||
|
const expectedPage = expectedTopics.slice(offset, offset + limit)
|
||||||
|
if (JSON.stringify(topics.map((t) => t.room)) !== JSON.stringify(expectedPage.map((t) => t.room))) return false
|
||||||
|
if (pagination.hasMore !== (offset + topics.length < pagination.total)) return false
|
||||||
|
|
||||||
for (const topic of topics) {
|
for (const topic of topics) {
|
||||||
const roomPosts = postEntries.filter((e) => e.value.room === topic.room).length
|
const roomPosts = postEntries.filter((e) => e.value.room === topic.room).length
|
||||||
@@ -139,7 +191,7 @@ test('listTopics conserves post counts and returns rooms sorted by most recent p
|
|||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
{ label: 'listTopics conservation and ordering' }
|
{ label: 'listTopics conservation, ordering, and pagination' }
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -204,6 +256,8 @@ test('getTopicPostTxids excludes muted addresses and conserves total and paginat
|
|||||||
const query = new TopicQuery({
|
const query = new TopicQuery({
|
||||||
roomsDb: makeRoomsDb(entries),
|
roomsDb: makeRoomsDb(entries),
|
||||||
postsDb: makePostsDb(entries),
|
postsDb: makePostsDb(entries),
|
||||||
|
topicSummariesDb: makeIndexDb([]),
|
||||||
|
topicRecencyDb: makeIndexDb([]),
|
||||||
muteQuery
|
muteQuery
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { assert } from 'chai'
|
import { assert } from 'chai'
|
||||||
import sinon from 'sinon'
|
import sinon from 'sinon'
|
||||||
import TopicQuery from '../../../src/adapters/topic-query.js'
|
import TopicQuery from '../../../src/adapters/topic-query.js'
|
||||||
|
import { topicRecencyKey } from '../../../src/lib/backfill-topic-indexes.js'
|
||||||
|
|
||||||
function makeRoomsDb (records = {}) {
|
function makeRoomsDb (records = {}) {
|
||||||
const store = new Map(Object.entries(records))
|
const store = new Map(Object.entries(records))
|
||||||
@@ -37,11 +38,38 @@ function makeRoomsDb (records = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// In-memory index store whose iterator honors the LevelDB `limit` option, so
|
||||||
|
// the read path's bounded recency reads can be asserted.
|
||||||
|
function makeIteratorDb (records = []) {
|
||||||
|
const store = new Map(records)
|
||||||
|
return {
|
||||||
|
async get (key) {
|
||||||
|
if (!store.has(key)) {
|
||||||
|
const err = new Error('not found')
|
||||||
|
err.notFound = true
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
return store.get(key)
|
||||||
|
},
|
||||||
|
async * iterator (opts = {}) {
|
||||||
|
let keys = Array.from(store.keys()).sort()
|
||||||
|
if (opts.gte !== undefined) keys = keys.filter((k) => k >= opts.gte)
|
||||||
|
if (opts.lte !== undefined) keys = keys.filter((k) => k <= opts.lte)
|
||||||
|
const limit = opts.limit === undefined ? keys.length : opts.limit
|
||||||
|
for (const key of keys.slice(0, limit)) {
|
||||||
|
yield [key, store.get(key)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
describe('#TopicQuery', () => {
|
describe('#TopicQuery', () => {
|
||||||
let uut
|
let uut
|
||||||
let sandbox
|
let sandbox
|
||||||
let roomsDb
|
let roomsDb
|
||||||
let postsDb
|
let postsDb
|
||||||
|
let topicSummariesDb
|
||||||
|
let topicRecencyDb
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
sandbox = sinon.createSandbox()
|
sandbox = sinon.createSandbox()
|
||||||
@@ -52,8 +80,10 @@ describe('#TopicQuery', () => {
|
|||||||
postsDb = {
|
postsDb = {
|
||||||
get: sandbox.stub()
|
get: sandbox.stub()
|
||||||
}
|
}
|
||||||
|
topicSummariesDb = makeIteratorDb()
|
||||||
|
topicRecencyDb = makeIteratorDb()
|
||||||
|
|
||||||
uut = new TopicQuery({ roomsDb, postsDb })
|
uut = new TopicQuery({ roomsDb, postsDb, topicSummariesDb, topicRecencyDb })
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => sandbox.restore())
|
afterEach(() => sandbox.restore())
|
||||||
@@ -71,13 +101,33 @@ describe('#TopicQuery', () => {
|
|||||||
it('should throw when postsDb is missing', () => {
|
it('should throw when postsDb is missing', () => {
|
||||||
try {
|
try {
|
||||||
// eslint-disable-next-line no-new
|
// eslint-disable-next-line no-new
|
||||||
new TopicQuery({ roomsDb })
|
new TopicQuery({ roomsDb, topicSummariesDb, topicRecencyDb })
|
||||||
assert.fail('Expected error')
|
assert.fail('Expected error')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
assert.include(err.message, 'postsDb required')
|
assert.include(err.message, 'postsDb required')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should throw when topicSummariesDb is missing', () => {
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line no-new
|
||||||
|
new TopicQuery({ roomsDb, postsDb, topicRecencyDb })
|
||||||
|
assert.fail('Expected error')
|
||||||
|
} catch (err) {
|
||||||
|
assert.include(err.message, 'topicSummariesDb required')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw when topicRecencyDb is missing', () => {
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line no-new
|
||||||
|
new TopicQuery({ roomsDb, postsDb, topicSummariesDb })
|
||||||
|
assert.fail('Expected error')
|
||||||
|
} catch (err) {
|
||||||
|
assert.include(err.message, 'topicRecencyDb required')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
describe('#roomFromKey', () => {
|
describe('#roomFromKey', () => {
|
||||||
it('should return the room from the value when present', () => {
|
it('should return the room from the value when present', () => {
|
||||||
assert.equal(uut.roomFromKey('ignored:post-1', { room: 'bitcoin' }), 'bitcoin')
|
assert.equal(uut.roomFromKey('ignored:post-1', { room: 'bitcoin' }), 'bitcoin')
|
||||||
@@ -95,60 +145,85 @@ describe('#TopicQuery', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('#listTopics', () => {
|
describe('#listTopics', () => {
|
||||||
it('should return distinct topics with post counts', async () => {
|
const summaries = [
|
||||||
async function * mockRooms () {
|
['memo', { room: 'memo', postCount: 5, lastHeight: 600500 }],
|
||||||
yield ['bitcoin:post-300', { room: 'bitcoin', txid: 'post-300', type: 'post', blockHeight: 300 }]
|
['cash', { room: 'cash', postCount: 2, lastHeight: 600400 }],
|
||||||
yield ['bitcoin:post-200', { room: 'bitcoin', txid: 'post-200', type: 'post', blockHeight: 200 }]
|
['dance', { room: 'dance', postCount: 3, lastHeight: 600400 }],
|
||||||
yield ['cash:post-250', { room: 'cash', txid: 'post-250', type: 'post', blockHeight: 250 }]
|
['anime', { room: 'anime', postCount: 1, lastHeight: 600300 }],
|
||||||
yield ['dev:post-100', { room: 'dev', txid: 'post-100', type: 'post', blockHeight: 100 }]
|
['lone', { room: 'lone', postCount: 0, lastHeight: 0 }],
|
||||||
yield ['lone:addr-f', { room: 'lone', addr: 'addr-f', type: 'follow', unfollow: false }]
|
['quiet', { room: 'quiet', postCount: 0, lastHeight: 0 }]
|
||||||
}
|
]
|
||||||
roomsDb.iterator.returns(mockRooms())
|
const recency = [
|
||||||
|
[topicRecencyKey(600500, 'memo'), { room: 'memo', blockHeight: 600500 }],
|
||||||
|
[topicRecencyKey(600400, 'cash'), { room: 'cash', blockHeight: 600400 }],
|
||||||
|
[topicRecencyKey(600400, 'dance'), { room: 'dance', blockHeight: 600400 }],
|
||||||
|
[topicRecencyKey(600300, 'anime'), { room: 'anime', blockHeight: 600300 }],
|
||||||
|
[topicRecencyKey(0, 'lone'), { room: 'lone', blockHeight: 0 }],
|
||||||
|
[topicRecencyKey(0, 'quiet'), { room: 'quiet', blockHeight: 0 }]
|
||||||
|
]
|
||||||
|
|
||||||
const result = await uut.listTopics()
|
it('should return topics ordered by recency with counts from summaries', async () => {
|
||||||
|
uut = new TopicQuery({
|
||||||
|
roomsDb,
|
||||||
|
postsDb,
|
||||||
|
topicSummariesDb: makeIteratorDb(summaries),
|
||||||
|
topicRecencyDb: makeIteratorDb(recency)
|
||||||
|
})
|
||||||
|
|
||||||
assert.deepEqual(result, [
|
const result = await uut.listTopics({ limit: 100, offset: 0 })
|
||||||
{ room: 'bitcoin', postCount: 2 },
|
|
||||||
{ room: 'cash', postCount: 1 },
|
assert.deepEqual(result.topics, [
|
||||||
{ room: 'dev', postCount: 1 },
|
{ room: 'memo', postCount: 5 },
|
||||||
{ room: 'lone', postCount: 0 }
|
{ room: 'cash', postCount: 2 },
|
||||||
|
{ room: 'dance', postCount: 3 },
|
||||||
|
{ room: 'anime', postCount: 1 },
|
||||||
|
{ room: 'lone', postCount: 0 },
|
||||||
|
{ room: 'quiet', postCount: 0 }
|
||||||
])
|
])
|
||||||
|
assert.deepEqual(result.pagination, { limit: 100, offset: 0, total: 6, hasMore: false })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should sort topics by most recent post descending', async () => {
|
it('should paginate using recency order and report total and hasMore', async () => {
|
||||||
async function * mockRooms () {
|
uut = new TopicQuery({
|
||||||
yield ['zoo:post-1', { room: 'zoo', txid: 'post-1', type: 'post', blockHeight: 1 }]
|
roomsDb,
|
||||||
yield ['alpha:post-1', { room: 'alpha', txid: 'post-1', type: 'post', blockHeight: 2 }]
|
postsDb,
|
||||||
}
|
topicSummariesDb: makeIteratorDb(summaries),
|
||||||
roomsDb.iterator.returns(mockRooms())
|
topicRecencyDb: makeIteratorDb(recency)
|
||||||
|
})
|
||||||
|
|
||||||
const result = await uut.listTopics()
|
const result = await uut.listTopics({ limit: 2, offset: 2 })
|
||||||
|
|
||||||
assert.deepEqual(result.map((t) => t.room), ['alpha', 'zoo'])
|
assert.deepEqual(result.topics.map((t) => t.room), ['dance', 'anime'])
|
||||||
|
assert.equal(result.pagination.total, 6)
|
||||||
|
assert.equal(result.pagination.hasMore, true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should treat a post at block height 0 as the least recent', async () => {
|
it('should report hasMore false on the last page', async () => {
|
||||||
async function * mockRooms () {
|
uut = new TopicQuery({
|
||||||
yield ['a:post-1', { room: 'a', txid: 'post-1', type: 'post', blockHeight: 0 }]
|
roomsDb,
|
||||||
yield ['b:post-1', { room: 'b', txid: 'post-1', type: 'post', blockHeight: 1 }]
|
postsDb,
|
||||||
}
|
topicSummariesDb: makeIteratorDb(summaries),
|
||||||
roomsDb.iterator.returns(mockRooms())
|
topicRecencyDb: makeIteratorDb(recency)
|
||||||
|
})
|
||||||
|
|
||||||
const result = await uut.listTopics()
|
const result = await uut.listTopics({ limit: 2, offset: 4 })
|
||||||
|
|
||||||
assert.deepEqual(result.map((t) => t.room), ['b', 'a'])
|
assert.deepEqual(result.topics.map((t) => t.room), ['lone', 'quiet'])
|
||||||
|
assert.equal(result.pagination.total, 6)
|
||||||
|
assert.equal(result.pagination.hasMore, false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should treat a post with no block height as height 0', async () => {
|
it('should read the recency index without iterating the rooms store', async () => {
|
||||||
async function * mockRooms () {
|
uut = new TopicQuery({
|
||||||
yield ['a:post-1', { room: 'a', txid: 'post-1', type: 'post' }]
|
roomsDb,
|
||||||
yield ['b:post-1', { room: 'b', txid: 'post-1', type: 'post', blockHeight: 1 }]
|
postsDb,
|
||||||
}
|
topicSummariesDb: makeIteratorDb(summaries),
|
||||||
roomsDb.iterator.returns(mockRooms())
|
topicRecencyDb: makeIteratorDb(recency)
|
||||||
|
})
|
||||||
|
|
||||||
const result = await uut.listTopics()
|
await uut.listTopics({ limit: 2, offset: 2 })
|
||||||
|
|
||||||
assert.deepEqual(result.map((t) => t.room), ['b', 'a'])
|
assert.equal(roomsDb.iterator.callCount, 0)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -258,7 +333,7 @@ describe('#TopicQuery', () => {
|
|||||||
const muteQuery = {
|
const muteQuery = {
|
||||||
listMuted: sandbox.stub().resolves(['muted-addr'])
|
listMuted: sandbox.stub().resolves(['muted-addr'])
|
||||||
}
|
}
|
||||||
uut = new TopicQuery({ roomsDb, postsDb, muteQuery })
|
uut = new TopicQuery({ roomsDb, postsDb, topicSummariesDb, topicRecencyDb, muteQuery })
|
||||||
|
|
||||||
const result = await uut.getTopicPostTxids('bitcoin', { limit: 100, offset: 0, viewerAddr: 'viewer-addr' })
|
const result = await uut.getTopicPostTxids('bitcoin', { limit: 100, offset: 0, viewerAddr: 'viewer-addr' })
|
||||||
|
|
||||||
@@ -320,7 +395,9 @@ describe('#TopicQuery', () => {
|
|||||||
'bitcoin:addr-c': { room: 'bitcoin', addr: 'addr-c', type: 'follow', unfollow: true },
|
'bitcoin:addr-c': { room: 'bitcoin', addr: 'addr-c', type: 'follow', unfollow: true },
|
||||||
'cash:addr-a': { room: 'cash', addr: 'addr-a', type: 'follow', unfollow: false }
|
'cash:addr-a': { room: 'cash', addr: 'addr-a', type: 'follow', unfollow: false }
|
||||||
}),
|
}),
|
||||||
postsDb
|
postsDb,
|
||||||
|
topicSummariesDb,
|
||||||
|
topicRecencyDb
|
||||||
})
|
})
|
||||||
|
|
||||||
const result = await query.listRoomFollowers('bitcoin')
|
const result = await query.listRoomFollowers('bitcoin')
|
||||||
@@ -332,7 +409,9 @@ describe('#TopicQuery', () => {
|
|||||||
roomsDb: makeRoomsDb({
|
roomsDb: makeRoomsDb({
|
||||||
'bitcoin:addr-a': { room: 'bitcoin', type: 'follow', unfollow: false }
|
'bitcoin:addr-a': { room: 'bitcoin', type: 'follow', unfollow: false }
|
||||||
}),
|
}),
|
||||||
postsDb
|
postsDb,
|
||||||
|
topicSummariesDb,
|
||||||
|
topicRecencyDb
|
||||||
})
|
})
|
||||||
|
|
||||||
const result = await query.listRoomFollowers('bitcoin')
|
const result = await query.listRoomFollowers('bitcoin')
|
||||||
@@ -342,7 +421,9 @@ describe('#TopicQuery', () => {
|
|||||||
it('should return an empty array for a room with no followers', async () => {
|
it('should return an empty array for a room with no followers', async () => {
|
||||||
const query = new TopicQuery({
|
const query = new TopicQuery({
|
||||||
roomsDb: makeRoomsDb({}),
|
roomsDb: makeRoomsDb({}),
|
||||||
postsDb
|
postsDb,
|
||||||
|
topicSummariesDb,
|
||||||
|
topicRecencyDb
|
||||||
})
|
})
|
||||||
|
|
||||||
const result = await query.listRoomFollowers('lone')
|
const result = await query.listRoomFollowers('lone')
|
||||||
|
|||||||
@@ -45,10 +45,11 @@ describe('#TopicsRESTController', () => {
|
|||||||
afterEach(() => sandbox.restore())
|
afterEach(() => sandbox.restore())
|
||||||
|
|
||||||
it('should return topics from use case', async () => {
|
it('should return topics from use case', async () => {
|
||||||
const ctx = { body: null, throw: sandbox.stub() }
|
const ctx = { query: { limit: '2', offset: '4' }, body: null, throw: sandbox.stub() }
|
||||||
await uut.getTopics(ctx)
|
await uut.getTopics(ctx)
|
||||||
|
|
||||||
assert.equal(uut.useCases.listTopics.execute.callCount, 1)
|
assert.equal(uut.useCases.listTopics.execute.callCount, 1)
|
||||||
|
assert.deepEqual(uut.useCases.listTopics.execute.firstCall.args[0], { limit: '2', offset: '4' })
|
||||||
assert.equal(ctx.body.topics.length, 2)
|
assert.equal(ctx.body.topics.length, 2)
|
||||||
assert.equal(ctx.body.topics[0].room, 'bitcoin')
|
assert.equal(ctx.body.topics[0].room, 'bitcoin')
|
||||||
})
|
})
|
||||||
@@ -106,7 +107,7 @@ describe('#TopicsRESTController', () => {
|
|||||||
|
|
||||||
it('should throw a 500 when the use case fails without a status', async () => {
|
it('should throw a 500 when the use case fails without a status', async () => {
|
||||||
uut.useCases.listTopics.execute = sandbox.stub().rejects(new Error('boom'))
|
uut.useCases.listTopics.execute = sandbox.stub().rejects(new Error('boom'))
|
||||||
const ctx = { body: null, throw: sandbox.stub() }
|
const ctx = { query: {}, body: null, throw: sandbox.stub() }
|
||||||
|
|
||||||
await uut.getTopics(ctx)
|
await uut.getTopics(ctx)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { assert } from 'chai'
|
||||||
|
import { backfillTopicIndexes, topicRecencyKey } from '../../../src/lib/backfill-topic-indexes.js'
|
||||||
|
|
||||||
|
function makeDb (records = []) {
|
||||||
|
const store = new Map(records)
|
||||||
|
return {
|
||||||
|
store,
|
||||||
|
async get (key) {
|
||||||
|
if (!store.has(key)) {
|
||||||
|
const err = new Error('not found')
|
||||||
|
err.notFound = true
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
return store.get(key)
|
||||||
|
},
|
||||||
|
async put (key, value) {
|
||||||
|
store.set(key, value)
|
||||||
|
},
|
||||||
|
async del (key) {
|
||||||
|
store.delete(key)
|
||||||
|
},
|
||||||
|
async * iterator () {
|
||||||
|
for (const key of Array.from(store.keys()).sort()) {
|
||||||
|
yield [key, store.get(key)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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:addr-f', { room: 'bitcoin', addr: 'addr-f', type: 'follow', unfollow: false }],
|
||||||
|
['cash:post-250', { room: 'cash', txid: 'post-250', type: 'post', blockHeight: 600250 }],
|
||||||
|
['lone:addr-f', { room: 'lone', addr: 'addr-f', type: 'follow', unfollow: false }]
|
||||||
|
])
|
||||||
|
const topicSummariesDb = makeDb()
|
||||||
|
const topicRecencyDb = makeDb()
|
||||||
|
|
||||||
|
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(topicRecencyDb.store.get(topicRecencyKey(600200, 'bitcoin')), { room: 'bitcoin', blockHeight: 600200 })
|
||||||
|
assert.deepEqual(topicRecencyDb.store.get(topicRecencyKey(600250, 'cash')), { room: 'cash', blockHeight: 600250 })
|
||||||
|
assert.deepEqual(topicRecencyDb.store.get(topicRecencyKey(0, 'lone')), { room: 'lone', blockHeight: 0 })
|
||||||
|
assert.equal(topicRecencyDb.store.size, 3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should be idempotent across repeated runs', async () => {
|
||||||
|
const roomsDb = makeDb([
|
||||||
|
['bitcoin:post-200', { room: 'bitcoin', txid: 'post-200', type: 'post', blockHeight: 600200 }],
|
||||||
|
['lone:addr-f', { room: 'lone', addr: 'addr-f', type: 'follow', unfollow: false }]
|
||||||
|
])
|
||||||
|
const topicSummariesDb = makeDb()
|
||||||
|
const topicRecencyDb = makeDb()
|
||||||
|
|
||||||
|
await backfillTopicIndexes({ roomsDb, topicSummariesDb, topicRecencyDb })
|
||||||
|
const firstSummaries = new Map(topicSummariesDb.store)
|
||||||
|
const firstRecency = new Map(topicRecencyDb.store)
|
||||||
|
|
||||||
|
await backfillTopicIndexes({ roomsDb, topicSummariesDb, topicRecencyDb })
|
||||||
|
|
||||||
|
assert.deepEqual(topicSummariesDb.store, firstSummaries)
|
||||||
|
assert.deepEqual(topicRecencyDb.store, firstRecency)
|
||||||
|
})
|
||||||
|
|
||||||
|
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 }]
|
||||||
|
])
|
||||||
|
const topicSummariesDb = makeDb()
|
||||||
|
const topicRecencyDb = makeDb([
|
||||||
|
[topicRecencyKey(600100, 'bitcoin'), { room: 'bitcoin', blockHeight: 600100 }],
|
||||||
|
[topicRecencyKey(0, 'stale'), { room: 'stale', blockHeight: 0 }]
|
||||||
|
])
|
||||||
|
|
||||||
|
await backfillTopicIndexes({ roomsDb, topicSummariesDb, topicRecencyDb })
|
||||||
|
|
||||||
|
assert.isFalse(topicRecencyDb.store.has(topicRecencyKey(600100, 'bitcoin')))
|
||||||
|
assert.isFalse(topicRecencyDb.store.has(topicRecencyKey(0, 'stale')))
|
||||||
|
assert.isTrue(topicRecencyDb.store.has(topicRecencyKey(600200, 'bitcoin')))
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -7,13 +7,18 @@ describe('#ListTopics', () => {
|
|||||||
let sandbox
|
let sandbox
|
||||||
let topicQuery
|
let topicQuery
|
||||||
|
|
||||||
|
const adapterResult = {
|
||||||
|
topics: [
|
||||||
|
{ room: 'bitcoin', postCount: 2 },
|
||||||
|
{ room: 'cash', postCount: 1 }
|
||||||
|
],
|
||||||
|
pagination: { limit: 100, offset: 0, total: 2, hasMore: false }
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
sandbox = sinon.createSandbox()
|
sandbox = sinon.createSandbox()
|
||||||
topicQuery = {
|
topicQuery = {
|
||||||
listTopics: sandbox.stub().resolves([
|
listTopics: sandbox.stub().resolves(adapterResult)
|
||||||
{ room: 'bitcoin', postCount: 2 },
|
|
||||||
{ room: 'cash', postCount: 1 }
|
|
||||||
])
|
|
||||||
}
|
}
|
||||||
uut = new ListTopics({
|
uut = new ListTopics({
|
||||||
adapters: { topicQuery }
|
adapters: { topicQuery }
|
||||||
@@ -42,12 +47,36 @@ describe('#ListTopics', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should return topics from the adapter', async () => {
|
it('should return the adapter page with default limit and offset', async () => {
|
||||||
const result = await uut.execute()
|
const result = await uut.execute()
|
||||||
|
|
||||||
assert.deepEqual(result.topics, [
|
assert.deepEqual(result, adapterResult)
|
||||||
{ room: 'bitcoin', postCount: 2 },
|
assert.deepEqual(topicQuery.listTopics.firstCall.args[0], { limit: 100, offset: 0 })
|
||||||
{ room: 'cash', postCount: 1 }
|
})
|
||||||
])
|
|
||||||
|
it('should forward limit and offset to the adapter', async () => {
|
||||||
|
await uut.execute({ limit: 2, offset: 4 })
|
||||||
|
|
||||||
|
assert.deepEqual(topicQuery.listTopics.firstCall.args[0], { limit: 2, offset: 4 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should reject a non-positive limit', async () => {
|
||||||
|
try {
|
||||||
|
await uut.execute({ limit: 0 })
|
||||||
|
assert.fail('Expected error')
|
||||||
|
} catch (err) {
|
||||||
|
assert.include(err.message, 'limit must be a positive integer')
|
||||||
|
assert.equal(err.status, 400)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should reject a negative offset', async () => {
|
||||||
|
try {
|
||||||
|
await uut.execute({ offset: -1 })
|
||||||
|
assert.fail('Expected error')
|
||||||
|
} catch (err) {
|
||||||
|
assert.include(err.message, 'offset must be a non-negative integer')
|
||||||
|
assert.equal(err.status, 400)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
/*
|
||||||
|
Utility: build the topicSummaries and topicRecency indexes for an existing
|
||||||
|
psf-memo-db.
|
||||||
|
|
||||||
|
New deployments write these indexes while indexing live topic messages, but
|
||||||
|
existing databases that were populated before the topic recency feature need
|
||||||
|
a one-time backfill.
|
||||||
|
|
||||||
|
Run from the psf-memo-db repo root on the host that owns the LevelDB files:
|
||||||
|
|
||||||
|
node util/room/backfill-topic-indexes.js
|
||||||
|
|
||||||
|
The script is idempotent: re-running it produces the same indexes. Progress
|
||||||
|
and a summary are printed to stderr.
|
||||||
|
|
||||||
|
WARNING:
|
||||||
|
- This script opens the LevelDB files directly. psf-memo-db must NOT be
|
||||||
|
running, or another process must not hold the database locks.
|
||||||
|
- Make a backup of leveldb/current before running on a production server:
|
||||||
|
cp -r leveldb/current leveldb/current-pre-topic-index-backup
|
||||||
|
*/
|
||||||
|
|
||||||
|
import level from 'level'
|
||||||
|
import * as fs from 'fs'
|
||||||
|
import * as path from 'path'
|
||||||
|
import * as url from 'url'
|
||||||
|
import { backfillTopicIndexes } from '../../src/lib/backfill-topic-indexes.js'
|
||||||
|
|
||||||
|
const __dirname = url.fileURLToPath(new URL('.', import.meta.url))
|
||||||
|
|
||||||
|
const DATA_DIR = process.env.PSF_MEMO_DB_DATA_DIR
|
||||||
|
? path.resolve(process.env.PSF_MEMO_DB_DATA_DIR)
|
||||||
|
: path.resolve(__dirname, '../../leveldb/current')
|
||||||
|
|
||||||
|
function requiredStorePath (dir, name) {
|
||||||
|
const storePath = path.join(dir, name)
|
||||||
|
if (!fs.existsSync(storePath)) {
|
||||||
|
throw new Error(`Required LevelDB store not found: ${storePath}. Set PSF_MEMO_DB_DATA_DIR to the directory containing the rooms store.`)
|
||||||
|
}
|
||||||
|
return storePath
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main () {
|
||||||
|
console.error(`Using LevelDB data directory: ${DATA_DIR}`)
|
||||||
|
|
||||||
|
const roomsPath = requiredStorePath(DATA_DIR, 'rooms')
|
||||||
|
const topicSummariesPath = path.join(DATA_DIR, 'topicSummaries')
|
||||||
|
const topicRecencyPath = path.join(DATA_DIR, 'topicRecency')
|
||||||
|
|
||||||
|
console.error('Opening LevelDB stores...')
|
||||||
|
const roomsDb = level(roomsPath, { valueEncoding: 'json' })
|
||||||
|
const topicSummariesDb = level(topicSummariesPath, { valueEncoding: 'json', createIfMissing: true })
|
||||||
|
const topicRecencyDb = level(topicRecencyPath, { valueEncoding: 'json', createIfMissing: true })
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.error('Backfilling topicSummaries and topicRecency from rooms...')
|
||||||
|
const summary = await backfillTopicIndexes({ roomsDb, topicSummariesDb, topicRecencyDb })
|
||||||
|
|
||||||
|
console.error('\nTopic index backfill complete.')
|
||||||
|
console.error(` rooms summarized: ${summary.rooms}`)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('\nTopic index backfill failed:', err.message)
|
||||||
|
process.exitCode = 1
|
||||||
|
} finally {
|
||||||
|
await topicRecencyDb.close().catch(() => {})
|
||||||
|
await topicSummariesDb.close().catch(() => {})
|
||||||
|
await roomsDb.close().catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
@@ -14,6 +14,9 @@ import { handleCreatePoll } from '../../src/use-cases/action-types/poll-create.j
|
|||||||
import { handleAddPollOption } from '../../src/use-cases/action-types/poll-option.js'
|
import { handleAddPollOption } from '../../src/use-cases/action-types/poll-option.js'
|
||||||
import { handlePollVote } from '../../src/use-cases/action-types/poll-vote.js'
|
import { handlePollVote } from '../../src/use-cases/action-types/poll-vote.js'
|
||||||
import { handleMute } from '../../src/use-cases/action-types/mute.js'
|
import { handleMute } from '../../src/use-cases/action-types/mute.js'
|
||||||
|
import { handleTopicMessage } from '../../src/use-cases/action-types/topic-message.js'
|
||||||
|
import { handleTopicFollow } from '../../src/use-cases/action-types/topic-follow.js'
|
||||||
|
import { topicRecencyKey } from '../../src/use-cases/action-types/helpers.js'
|
||||||
import BackupDb from '../../src/use-cases/backup-db.js'
|
import BackupDb from '../../src/use-cases/backup-db.js'
|
||||||
|
|
||||||
function makeInMemoryDb () {
|
function makeInMemoryDb () {
|
||||||
@@ -85,6 +88,9 @@ async function createWorld () {
|
|||||||
const pollOptionDb = makeInMemoryDb()
|
const pollOptionDb = makeInMemoryDb()
|
||||||
const pollVoteDb = makeInMemoryDb()
|
const pollVoteDb = makeInMemoryDb()
|
||||||
const muteDb = makeInMemoryDb()
|
const muteDb = makeInMemoryDb()
|
||||||
|
const roomDb = makeInMemoryDb()
|
||||||
|
const topicSummaryDb = makeInMemoryDb()
|
||||||
|
const topicRecencyDb = makeInMemoryDb()
|
||||||
|
|
||||||
const adapters = {
|
const adapters = {
|
||||||
postDb: postsDb,
|
postDb: postsDb,
|
||||||
@@ -98,6 +104,9 @@ async function createWorld () {
|
|||||||
pollOptionDb,
|
pollOptionDb,
|
||||||
pollVoteDb,
|
pollVoteDb,
|
||||||
muteDb,
|
muteDb,
|
||||||
|
roomDb,
|
||||||
|
topicSummaryDb,
|
||||||
|
topicRecencyDb,
|
||||||
processErrorDb: makeInMemoryDb(),
|
processErrorDb: makeInMemoryDb(),
|
||||||
dbCtrl: {
|
dbCtrl: {
|
||||||
backupDb: async (height, epoch) => {
|
backupDb: async (height, epoch) => {
|
||||||
@@ -121,6 +130,9 @@ async function createWorld () {
|
|||||||
pollOptionsDb: pollOptionDb,
|
pollOptionsDb: pollOptionDb,
|
||||||
pollVotesDb: pollVoteDb,
|
pollVotesDb: pollVoteDb,
|
||||||
mutesDb: muteDb,
|
mutesDb: muteDb,
|
||||||
|
roomsDb: roomDb,
|
||||||
|
topicSummariesDb: topicSummaryDb,
|
||||||
|
topicRecencyDb,
|
||||||
txidMap: new Map(),
|
txidMap: new Map(),
|
||||||
lastTxid: null,
|
lastTxid: null,
|
||||||
lastHeight: null,
|
lastHeight: null,
|
||||||
@@ -697,6 +709,112 @@ const muteHandlers = [
|
|||||||
throw new Error(`Expected no mute document for txid ${txid}, but one was stored`)
|
throw new Error(`Expected no mute document for txid ${txid}, but one was stored`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'db instance with rooms and topic index stores',
|
||||||
|
pattern: /^a psf-memo-db instance with rooms, topicSummaries, and topicRecency stores$/,
|
||||||
|
async run () {
|
||||||
|
// World is already created with the room and topic index stores.
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'process a Memo topic message',
|
||||||
|
pattern: /^the indexer processes a Memo topic message (.+) in room "(.+)" from (.+) at block height (.+) with text "(.+)"$/,
|
||||||
|
async run (m, example, world) {
|
||||||
|
const txid = resolveTxid(m[1], example, world)
|
||||||
|
const room = m[2]
|
||||||
|
const addr = resolveParam(m[3], example)
|
||||||
|
const height = parseInt(resolveParam(m[4], example), 10)
|
||||||
|
const text = m[5]
|
||||||
|
|
||||||
|
world.lastTxid = txid
|
||||||
|
world.lastHeight = height
|
||||||
|
world.lastAddr = addr
|
||||||
|
|
||||||
|
const prefix = Buffer.from('6d0c', 'hex')
|
||||||
|
const ctx = {
|
||||||
|
adapters: world.adapters,
|
||||||
|
txid,
|
||||||
|
signerAddr: addr,
|
||||||
|
seen: Date.now(),
|
||||||
|
blockHeight: height,
|
||||||
|
decoded: {
|
||||||
|
action: 'topicMessage',
|
||||||
|
prefix,
|
||||||
|
pushDatas: [prefix, Buffer.from(room, 'utf8'), Buffer.from(text, 'utf8')]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
world.lastTopicMessage = ctx
|
||||||
|
|
||||||
|
await handleTopicMessage(ctx)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'reprocess the last Memo topic message',
|
||||||
|
pattern: /^the indexer processes the same Memo topic message (.+) again$/,
|
||||||
|
async run (m, example, world) {
|
||||||
|
if (!world.lastTopicMessage) {
|
||||||
|
throw new Error('No previous topic message to reprocess')
|
||||||
|
}
|
||||||
|
await handleTopicMessage(world.lastTopicMessage)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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 addr = resolveParam(m[2], example)
|
||||||
|
const txid = deriveTxid(`topic-follow-${room}-${addr}`)
|
||||||
|
|
||||||
|
world.lastTxid = txid
|
||||||
|
world.lastAddr = addr
|
||||||
|
|
||||||
|
const prefix = Buffer.from('6d0d', 'hex')
|
||||||
|
await handleTopicFollow({
|
||||||
|
adapters: world.adapters,
|
||||||
|
txid,
|
||||||
|
signerAddr: addr,
|
||||||
|
seen: Date.now(),
|
||||||
|
blockHeight: 600000,
|
||||||
|
decoded: {
|
||||||
|
action: 'topicFollow',
|
||||||
|
prefix,
|
||||||
|
pushDatas: [prefix, Buffer.from(room, 'utf8')]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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 postCount = parseInt(resolveParam(m[2], example), 10)
|
||||||
|
const lastHeight = parseInt(resolveParam(m[3], example), 10)
|
||||||
|
const record = await world.topicSummariesDb.get(room)
|
||||||
|
if (!record) {
|
||||||
|
throw new Error(`No topicSummaries record for ${room}`)
|
||||||
|
}
|
||||||
|
if (record.room !== room || record.postCount !== postCount || record.lastHeight !== lastHeight) {
|
||||||
|
throw new Error(`Expected ${room} postCount ${postCount} lastHeight ${lastHeight}, 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 height = parseInt(resolveParam(m[2], example), 10)
|
||||||
|
const record = await world.topicRecencyDb.get(topicRecencyKey(height, room))
|
||||||
|
if (!record) {
|
||||||
|
throw new Error(`No topicRecency record for ${room} at ${height}`)
|
||||||
|
}
|
||||||
|
if (record.room !== room || record.blockHeight !== height) {
|
||||||
|
throw new Error(`Expected topicRecency ${room} at ${height}, got ${JSON.stringify(record)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ class Adapters {
|
|||||||
this.followDb = createEntityDb('follow', 'key', 'followData')
|
this.followDb = createEntityDb('follow', 'key', 'followData')
|
||||||
this.muteDb = createEntityDb('mute', 'key', 'muteData')
|
this.muteDb = createEntityDb('mute', 'key', 'muteData')
|
||||||
this.roomDb = createEntityDb('room', 'key', 'roomData')
|
this.roomDb = createEntityDb('room', 'key', 'roomData')
|
||||||
|
this.topicSummaryDb = createEntityDb('topicsummary', 'key', 'topicSummaryData')
|
||||||
|
this.topicRecencyDb = createEntityDb('topicrecency', 'key', 'topicRecencyData')
|
||||||
this.pollDb = createEntityDb('poll', 'txid', 'pollData')
|
this.pollDb = createEntityDb('poll', 'txid', 'pollData')
|
||||||
this.pollOptionDb = createEntityDb('polloption', 'txid', 'optionData')
|
this.pollOptionDb = createEntityDb('polloption', 'txid', 'optionData')
|
||||||
this.pollVoteDb = createEntityDb('pollvote', 'txid', 'voteData')
|
this.pollVoteDb = createEntityDb('pollvote', 'txid', 'voteData')
|
||||||
|
|||||||
@@ -62,6 +62,30 @@ export function roomKey (roomName, txid) {
|
|||||||
return `${roomName}:${txid}`
|
return `${roomName}:${txid}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The topicRecency store is keyed with an inverted height so a plain iterator
|
||||||
|
// yields rooms ordered by most recent post height and, within a height, by
|
||||||
|
// room name ascending.
|
||||||
|
export function topicRecencyKey (blockHeight, roomName) {
|
||||||
|
const inverted = 999999999999 - (blockHeight ?? 0)
|
||||||
|
return `${String(inverted).padStart(12, '0')}:${roomName}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// True for the LevelDB not-found error, its in-memory equivalent, and the
|
||||||
|
// 404 surfaced by the psf-memo-db entity routes.
|
||||||
|
export function isNotFound (err) {
|
||||||
|
return Boolean(err && (err.notFound || err.code === 'LEVEL_NOT_FOUND' || err.response?.status === 404))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read a record, or null when it does not exist. Rethrows real errors.
|
||||||
|
export async function getIfPresent (db, key) {
|
||||||
|
try {
|
||||||
|
return await db.get(key)
|
||||||
|
} catch (err) {
|
||||||
|
if (isNotFound(err)) return null
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function postHeightKey (blockHeight, txid) {
|
export function postHeightKey (blockHeight, txid) {
|
||||||
const padded = String(blockHeight).padStart(12, '0')
|
const padded = String(blockHeight).padStart(12, '0')
|
||||||
return `${padded}:${txid}`
|
return `${padded}:${txid}`
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { utf8FromPush, logProcessError, roomKey } from './helpers.js'
|
import { utf8FromPush, logProcessError, roomKey } from './helpers.js'
|
||||||
import { PREFIX_TOPIC_UNFOLLOW } from '../../lib/memo-codes.js'
|
import { PREFIX_TOPIC_UNFOLLOW } from '../../lib/memo-codes.js'
|
||||||
|
import { ensureTopicRoom } from './topic-indexing.js'
|
||||||
|
|
||||||
export async function handleTopicFollow (ctx) {
|
export async function handleTopicFollow (ctx) {
|
||||||
const { adapters, txid, signerAddr, decoded, seen, blockHeight } = ctx
|
const { adapters, txid, signerAddr, decoded, seen, blockHeight } = ctx
|
||||||
@@ -22,4 +23,8 @@ export async function handleTopicFollow (ctx) {
|
|||||||
type: 'follow',
|
type: 'follow',
|
||||||
blockHeight
|
blockHeight
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (!unfollow) {
|
||||||
|
await ensureTopicRoom(adapters, room)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/*
|
||||||
|
Maintain the topicSummaries and topicRecency indexes for Memo topic activity.
|
||||||
|
|
||||||
|
topicSummaries: one record per room keyed by room, value
|
||||||
|
{ room, postCount, lastHeight }.
|
||||||
|
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.
|
||||||
|
|
||||||
|
Both indexes are written with the generic entity `update`, which upserts, so
|
||||||
|
re-running an action is safe.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getIfPresent, 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) {
|
||||||
|
const height = blockHeight ?? 0
|
||||||
|
const summary = await getIfPresent(adapters.topicSummaryDb, room)
|
||||||
|
const lastHeight = Math.max(summary?.lastHeight ?? 0, height)
|
||||||
|
|
||||||
|
await adapters.topicSummaryDb.update(room, {
|
||||||
|
room,
|
||||||
|
postCount: (summary?.postCount ?? 0) + 1,
|
||||||
|
lastHeight
|
||||||
|
})
|
||||||
|
|
||||||
|
// When the room moved to a newer height, delete the stale recency record so
|
||||||
|
// each room has exactly one recency entry.
|
||||||
|
if (summary && (summary.lastHeight ?? 0) !== lastHeight) {
|
||||||
|
await adapters.topicRecencyDb.delete(topicRecencyKey(summary.lastHeight ?? 0, room))
|
||||||
|
}
|
||||||
|
|
||||||
|
await adapters.topicRecencyDb.update(topicRecencyKey(lastHeight, room), {
|
||||||
|
room,
|
||||||
|
blockHeight: lastHeight
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
export async function ensureTopicRoom (adapters, room) {
|
||||||
|
const summary = await getIfPresent(adapters.topicSummaryDb, room)
|
||||||
|
if (summary) return
|
||||||
|
|
||||||
|
await adapters.topicSummaryDb.update(room, { room, postCount: 0, lastHeight: 0 })
|
||||||
|
await adapters.topicRecencyDb.update(topicRecencyKey(0, room), { room, blockHeight: 0 })
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { utf8FromPush, logProcessError, roomKey } from './helpers.js'
|
import { utf8FromPush, logProcessError, roomKey, getIfPresent } from './helpers.js'
|
||||||
import { MAX_POST_SIZE } from '../../lib/memo-codes.js'
|
import { MAX_POST_SIZE } from '../../lib/memo-codes.js'
|
||||||
import { handlePost } from './post.js'
|
import { handlePost } from './post.js'
|
||||||
|
import { recordTopicPost } from './topic-indexing.js'
|
||||||
|
|
||||||
export async function handleTopicMessage (ctx) {
|
export async function handleTopicMessage (ctx) {
|
||||||
const { adapters, txid, decoded, seen, blockHeight } = ctx
|
const { adapters, txid, decoded, seen, blockHeight } = ctx
|
||||||
@@ -18,10 +19,20 @@ export async function handleTopicMessage (ctx) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The room record is the marker that this topic message was already
|
||||||
|
// indexed. Check it before writing so reprocessing does not double-count the
|
||||||
|
// room's post summary.
|
||||||
|
const key = roomKey(room, txid)
|
||||||
|
const alreadyIndexed = (await getIfPresent(adapters.roomDb, key)) !== null
|
||||||
|
|
||||||
await handlePost({
|
await handlePost({
|
||||||
...ctx,
|
...ctx,
|
||||||
decoded: { ...decoded, pushDatas: [pushDatas[0], pushDatas[2]] }
|
decoded: { ...decoded, pushDatas: [pushDatas[0], pushDatas[2]] }
|
||||||
})
|
})
|
||||||
|
|
||||||
await adapters.roomDb.create(roomKey(room, txid), { room, txid, seen, type: 'post', blockHeight })
|
await adapters.roomDb.create(key, { room, txid, seen, type: 'post', blockHeight })
|
||||||
|
|
||||||
|
if (!alreadyIndexed) {
|
||||||
|
await recordTopicPost(adapters, room, blockHeight)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { assert } from 'chai'
|
||||||
|
import { handleTopicFollow } from '../../../../src/use-cases/action-types/topic-follow.js'
|
||||||
|
import { PREFIX_TOPIC_FOLLOW, PREFIX_TOPIC_UNFOLLOW } from '../../../../src/lib/memo-codes.js'
|
||||||
|
import { topicRecencyKey } from '../../../../src/use-cases/action-types/helpers.js'
|
||||||
|
|
||||||
|
function makeDb () {
|
||||||
|
const store = new Map()
|
||||||
|
return {
|
||||||
|
store,
|
||||||
|
async get (key) {
|
||||||
|
if (!store.has(key)) {
|
||||||
|
const err = new Error('not found')
|
||||||
|
err.notFound = true
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
return store.get(key)
|
||||||
|
},
|
||||||
|
async create (key, value) {
|
||||||
|
store.set(key, value)
|
||||||
|
return { success: true }
|
||||||
|
},
|
||||||
|
async update (key, value) {
|
||||||
|
store.set(key, value)
|
||||||
|
return { success: true }
|
||||||
|
},
|
||||||
|
async delete (key) {
|
||||||
|
store.delete(key)
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeAdapters () {
|
||||||
|
return {
|
||||||
|
roomDb: makeDb(),
|
||||||
|
topicSummaryDb: makeDb(),
|
||||||
|
topicRecencyDb: makeDb(),
|
||||||
|
processErrorDb: makeDb()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processFollow (adapters, { txid, room, addr, prefix = PREFIX_TOPIC_FOLLOW }) {
|
||||||
|
await handleTopicFollow({
|
||||||
|
adapters,
|
||||||
|
txid,
|
||||||
|
signerAddr: addr,
|
||||||
|
seen: 1,
|
||||||
|
blockHeight: 600000,
|
||||||
|
decoded: {
|
||||||
|
action: 'topic-follow',
|
||||||
|
prefix,
|
||||||
|
pushDatas: [prefix, Buffer.from(room, 'utf8')]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('#handleTopicFollow topic indexes', () => {
|
||||||
|
it('should record a zero-post room for a follow-only room', async () => {
|
||||||
|
const adapters = makeAdapters()
|
||||||
|
|
||||||
|
await processFollow(adapters, { txid: 'follow-1', room: 'lone', addr: 'bitcoincash:qaddr-a' })
|
||||||
|
|
||||||
|
assert.deepEqual(adapters.topicSummaryDb.store.get('lone'), {
|
||||||
|
room: 'lone',
|
||||||
|
postCount: 0,
|
||||||
|
lastHeight: 0
|
||||||
|
})
|
||||||
|
assert.deepEqual(adapters.topicRecencyDb.store.get(topicRecencyKey(0, 'lone')), {
|
||||||
|
room: 'lone',
|
||||||
|
blockHeight: 0
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should leave an existing room summary unchanged', async () => {
|
||||||
|
const adapters = makeAdapters()
|
||||||
|
adapters.topicSummaryDb.store.set('bitcoin', { room: 'bitcoin', postCount: 1, lastHeight: 600400 })
|
||||||
|
adapters.topicRecencyDb.store.set(topicRecencyKey(600400, 'bitcoin'), { room: 'bitcoin', blockHeight: 600400 })
|
||||||
|
|
||||||
|
await processFollow(adapters, { txid: 'follow-2', room: 'bitcoin', addr: 'bitcoincash:qaddr-a' })
|
||||||
|
|
||||||
|
assert.deepEqual(adapters.topicSummaryDb.store.get('bitcoin'), {
|
||||||
|
room: 'bitcoin',
|
||||||
|
postCount: 1,
|
||||||
|
lastHeight: 600400
|
||||||
|
})
|
||||||
|
assert.deepEqual(adapters.topicRecencyDb.store.get(topicRecencyKey(600400, 'bitcoin')), {
|
||||||
|
room: 'bitcoin',
|
||||||
|
blockHeight: 600400
|
||||||
|
})
|
||||||
|
assert.isFalse(adapters.topicRecencyDb.store.has(topicRecencyKey(0, 'bitcoin')))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not create a zero-post room for an unfollow', async () => {
|
||||||
|
const adapters = makeAdapters()
|
||||||
|
|
||||||
|
await processFollow(adapters, {
|
||||||
|
txid: 'unfollow-1',
|
||||||
|
room: 'lone',
|
||||||
|
addr: 'bitcoincash:qaddr-a',
|
||||||
|
prefix: PREFIX_TOPIC_UNFOLLOW
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(adapters.topicSummaryDb.store.size, 0)
|
||||||
|
assert.equal(adapters.topicRecencyDb.store.size, 0)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { assert } from 'chai'
|
||||||
|
import { handleTopicMessage } from '../../../../src/use-cases/action-types/topic-message.js'
|
||||||
|
import { topicRecencyKey } from '../../../../src/use-cases/action-types/helpers.js'
|
||||||
|
|
||||||
|
function makeDb () {
|
||||||
|
const store = new Map()
|
||||||
|
return {
|
||||||
|
store,
|
||||||
|
async get (key) {
|
||||||
|
if (!store.has(key)) {
|
||||||
|
const err = new Error('not found')
|
||||||
|
err.notFound = true
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
return store.get(key)
|
||||||
|
},
|
||||||
|
async create (key, value) {
|
||||||
|
store.set(key, value)
|
||||||
|
return { success: true }
|
||||||
|
},
|
||||||
|
async update (key, value) {
|
||||||
|
store.set(key, value)
|
||||||
|
return { success: true }
|
||||||
|
},
|
||||||
|
async delete (key) {
|
||||||
|
store.delete(key)
|
||||||
|
return { success: true }
|
||||||
|
},
|
||||||
|
entries () {
|
||||||
|
return Array.from(store.entries())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeAdapters () {
|
||||||
|
return {
|
||||||
|
postDb: makeDb(),
|
||||||
|
postHeightDb: makeDb(),
|
||||||
|
addrPostHeightDb: makeDb(),
|
||||||
|
roomDb: makeDb(),
|
||||||
|
topicSummaryDb: makeDb(),
|
||||||
|
topicRecencyDb: makeDb(),
|
||||||
|
processErrorDb: makeDb()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processTopicMessage (adapters, { txid, room, addr, height, text }) {
|
||||||
|
const prefix = Buffer.from('6d0c', 'hex')
|
||||||
|
await handleTopicMessage({
|
||||||
|
adapters,
|
||||||
|
txid,
|
||||||
|
signerAddr: addr,
|
||||||
|
seen: 1,
|
||||||
|
blockHeight: height,
|
||||||
|
decoded: {
|
||||||
|
action: 'topic-message',
|
||||||
|
prefix,
|
||||||
|
pushDatas: [prefix, Buffer.from(room, 'utf8'), Buffer.from(text, 'utf8')]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('#handleTopicMessage topic indexes', () => {
|
||||||
|
it('should record a room summary and a recency record', async () => {
|
||||||
|
const adapters = makeAdapters()
|
||||||
|
|
||||||
|
await processTopicMessage(adapters, {
|
||||||
|
txid: 'topic-a1',
|
||||||
|
room: 'bitcoin',
|
||||||
|
addr: 'bitcoincash:qaddr-a',
|
||||||
|
height: 600100,
|
||||||
|
text: 'hello'
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.deepEqual(adapters.topicSummaryDb.store.get('bitcoin'), {
|
||||||
|
room: 'bitcoin',
|
||||||
|
postCount: 1,
|
||||||
|
lastHeight: 600100
|
||||||
|
})
|
||||||
|
assert.deepEqual(adapters.topicRecencyDb.store.get(topicRecencyKey(600100, 'bitcoin')), {
|
||||||
|
room: 'bitcoin',
|
||||||
|
blockHeight: 600100
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should accumulate postCount and keep the newest height', async () => {
|
||||||
|
const adapters = makeAdapters()
|
||||||
|
|
||||||
|
await processTopicMessage(adapters, { txid: 'topic-a1', room: 'bitcoin', addr: 'bitcoincash:qaddr-a', height: 600100, text: 'hello' })
|
||||||
|
await processTopicMessage(adapters, { txid: 'topic-a2', room: 'bitcoin', addr: 'bitcoincash:qaddr-a', height: 600200, text: 'again' })
|
||||||
|
|
||||||
|
assert.deepEqual(adapters.topicSummaryDb.store.get('bitcoin'), {
|
||||||
|
room: 'bitcoin',
|
||||||
|
postCount: 2,
|
||||||
|
lastHeight: 600200
|
||||||
|
})
|
||||||
|
assert.deepEqual(adapters.topicRecencyDb.store.get(topicRecencyKey(600200, 'bitcoin')), {
|
||||||
|
room: 'bitcoin',
|
||||||
|
blockHeight: 600200
|
||||||
|
})
|
||||||
|
assert.equal(adapters.topicRecencyDb.store.size, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should keep the newest height when a later message has an earlier height', async () => {
|
||||||
|
const adapters = makeAdapters()
|
||||||
|
|
||||||
|
await processTopicMessage(adapters, { txid: 'topic-a3', room: 'bitcoin', addr: 'bitcoincash:qaddr-a', height: 600200, text: 'later' })
|
||||||
|
await processTopicMessage(adapters, { txid: 'topic-a4', room: 'bitcoin', addr: 'bitcoincash:qaddr-a', height: 600100, text: 'earlier' })
|
||||||
|
|
||||||
|
assert.deepEqual(adapters.topicSummaryDb.store.get('bitcoin'), {
|
||||||
|
room: 'bitcoin',
|
||||||
|
postCount: 2,
|
||||||
|
lastHeight: 600200
|
||||||
|
})
|
||||||
|
assert.deepEqual(adapters.topicRecencyDb.store.get(topicRecencyKey(600200, 'bitcoin')), {
|
||||||
|
room: 'bitcoin',
|
||||||
|
blockHeight: 600200
|
||||||
|
})
|
||||||
|
assert.equal(adapters.topicRecencyDb.store.size, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not double-count a reprocessed topic message', async () => {
|
||||||
|
const adapters = makeAdapters()
|
||||||
|
|
||||||
|
await processTopicMessage(adapters, { txid: 'topic-c1', room: 'bitcoin', addr: 'bitcoincash:qaddr-c', height: 600300, text: 'repeated' })
|
||||||
|
await processTopicMessage(adapters, { txid: 'topic-c1', room: 'bitcoin', addr: 'bitcoincash:qaddr-c', height: 600300, text: 'repeated' })
|
||||||
|
|
||||||
|
assert.deepEqual(adapters.topicSummaryDb.store.get('bitcoin'), {
|
||||||
|
room: 'bitcoin',
|
||||||
|
postCount: 1,
|
||||||
|
lastHeight: 600300
|
||||||
|
})
|
||||||
|
assert.equal(adapters.topicRecencyDb.store.size, 1)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user