Merge commit 'f45f2f9e2a' into swarmforge-refactorer

This commit is contained in:
Chris Troutner
2026-09-04 06:43:42 -07:00
6 changed files with 65 additions and 23 deletions
@@ -50,7 +50,7 @@ function AppBody (props) {
<Route path='/wallet' element={<Wallet appData={appData} />} /> <Route path='/wallet' element={<Wallet appData={appData} />} />
<Route path='/slp-tokens' element={<SlpTokens appData={appData} />} /> <Route path='/slp-tokens' element={<SlpTokens appData={appData} />} />
<Route path='/profile/recent' element={<RecentProfiles />} /> <Route path='/profile/recent' element={<RecentProfiles />} />
<Route path='/profile/:addr' element={<Profile />} /> <Route path='/profile/:addr' element={<Profile appData={appData} />} />
<Route path='/posts/recent' element={<RecentPosts appData={appData} />} /> <Route path='/posts/recent' element={<RecentPosts appData={appData} />} />
<Route path='/posts/new' element={<NewPost appData={appData} />} /> <Route path='/posts/new' element={<NewPost appData={appData} />} />
<Route path='/topics' element={<Topics />} /> <Route path='/topics' element={<Topics />} />
+13 -2
View File
@@ -373,7 +373,7 @@ async function loadTopicsWithPosts (world) {
{ key: 'bitcoin:post-200', room: 'bitcoin', txid: 'post-200', type: 'post', blockHeight: 200 }, { key: 'bitcoin:post-200', room: 'bitcoin', txid: 'post-200', type: 'post', blockHeight: 200 },
{ key: 'bitcoin:addr-f', room: 'bitcoin', addr: 'addr-f', type: 'follow', unfollow: false }, { 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: 250 }, { key: 'cash:post-250', room: 'cash', txid: 'post-250', type: 'post', blockHeight: 250 },
{ key: 'dev:post-100', room: 'dev', txid: 'post-100', type: 'post', blockHeight: 100 }, { key: 'dev:post-400', room: 'dev', txid: 'post-400', type: 'post', blockHeight: 400 },
{ key: 'lone:addr-f', room: 'lone', addr: 'addr-f', type: 'follow', unfollow: false } { key: 'lone:addr-f', room: 'lone', addr: 'addr-f', type: 'follow', unfollow: false }
] ]
@@ -381,7 +381,7 @@ async function loadTopicsWithPosts (world) {
'post-300': { addr: 'addr-a', text: 'hello bitcoin', seen: 1, blockHeight: 300 }, 'post-300': { addr: 'addr-a', text: 'hello bitcoin', seen: 1, blockHeight: 300 },
'post-200': { addr: 'addr-b', text: 'bitcoin again', seen: 2, blockHeight: 200 }, 'post-200': { addr: 'addr-b', text: 'bitcoin again', seen: 2, blockHeight: 200 },
'post-250': { addr: 'addr-a', text: 'cash rules', seen: 3, blockHeight: 250 }, 'post-250': { addr: 'addr-a', text: 'cash rules', seen: 3, blockHeight: 250 },
'post-100': { addr: 'addr-c', text: 'dev stuff', seen: 4, blockHeight: 100 } 'post-400': { addr: 'addr-c', text: 'dev stuff', seen: 4, blockHeight: 400 }
} }
for (const entry of roomEntries) { for (const entry of roomEntries) {
@@ -868,6 +868,17 @@ const handlers = [
} }
} }
}, },
{
name: 'response lists topics in order',
pattern: /^the response lists topics in order (<expected_order>)$/,
run (m, example, world) {
const expected = resolveParam(m[1], example).split(',').map((s) => s.trim())
const actual = world.getLastResponse().topics.map((t) => t.room)
if (expected.join(',') !== actual.join(',')) {
throw new Error(`Expected topics ${expected.join(',')}, got ${actual.join(',')}`)
}
}
},
{ {
name: 'request topic posts', name: 'request topic posts',
pattern: /^the client requests \/topics\/([^/]+)\/posts(?: with limit (<limit>) and offset (<offset>))?$/, pattern: /^the client requests \/topics\/([^/]+)\/posts(?: with limit (<limit>) and offset (<offset>))?$/,
+11 -3
View File
@@ -2,7 +2,7 @@
# {"version":1,"tested_at":"2026-08-28T15:47:38.414053749Z","feature_name":"Topic Read","feature_path":"/home/trout/work/psf-memo/.worktrees/architect/psf-memo-db/specs/topic-read.feature","background_hash":"922fd45b8606f723073833f0aaca0e6d92b03a263a6b54c60490843e0ac41cc0","implementation_hash":"unknown","scenarios":[{"index":0,"name":"Topic Read - 1 GET /topics lists distinct topics with their post counts","scenario_hash":"a425a0cbf18c7e7fcb7d01c3571556d4c184b013ad7990e724d95d8d100eebb9","mutation_count":8,"result":{"Total":8,"Killed":8,"Survived":0,"Errors":0},"tested_at":"2026-08-28T15:39:33.364455310Z"},{"index":1,"name":"Topic Read - 2 GET /topics/:room/posts returns the posts for a topic sorted by block height descending","scenario_hash":"cffb702052b0df29aa87510e0c45dd01b83c4607e7ce915bc0e660d601f79cce","mutation_count":4,"result":{"Total":4,"Killed":4,"Survived":0,"Errors":0},"tested_at":"2026-08-28T15:39:33.364455310Z"}]} # {"version":1,"tested_at":"2026-08-28T15:47:38.414053749Z","feature_name":"Topic Read","feature_path":"/home/trout/work/psf-memo/.worktrees/architect/psf-memo-db/specs/topic-read.feature","background_hash":"922fd45b8606f723073833f0aaca0e6d92b03a263a6b54c60490843e0ac41cc0","implementation_hash":"unknown","scenarios":[{"index":0,"name":"Topic Read - 1 GET /topics lists distinct topics with their post counts","scenario_hash":"a425a0cbf18c7e7fcb7d01c3571556d4c184b013ad7990e724d95d8d100eebb9","mutation_count":8,"result":{"Total":8,"Killed":8,"Survived":0,"Errors":0},"tested_at":"2026-08-28T15:39:33.364455310Z"},{"index":1,"name":"Topic Read - 2 GET /topics/:room/posts returns the posts for a topic sorted by block height descending","scenario_hash":"cffb702052b0df29aa87510e0c45dd01b83c4607e7ce915bc0e660d601f79cce","mutation_count":4,"result":{"Total":4,"Killed":4,"Survived":0,"Errors":0},"tested_at":"2026-08-28T15:39:33.364455310Z"}]}
# acceptance-mutation-manifest-end # acceptance-mutation-manifest-end
# Scenarios: Topic Read - 1, Topic Read - 2, Topic Read - 3, Topic Read - 4 # Scenarios: Topic Read - 1, Topic Read - 2, Topic Read - 3, Topic Read - 4, Topic Read - 5
# #
# The indexer stores topic activity in the rooms store. Topic messages are # The indexer stores topic activity in the rooms store. Topic messages are
# keyed `${room}:${txid}` with type 'post'; topic follows are keyed # keyed `${room}:${txid}` with type 'post'; topic follows are keyed
@@ -15,13 +15,13 @@
# bitcoin:post-200 { room: bitcoin, txid: post-200, type: post, blockHeight: 200 } # bitcoin:post-200 { room: bitcoin, txid: post-200, type: post, blockHeight: 200 }
# bitcoin:addr-f { room: bitcoin, addr: addr-f, type: follow, unfollow: false } # bitcoin:addr-f { room: bitcoin, addr: addr-f, type: follow, unfollow: false }
# cash:post-250 { room: cash, txid: post-250, type: post, blockHeight: 250 } # cash:post-250 { room: cash, txid: post-250, type: post, blockHeight: 250 }
# dev:post-100 { room: dev, txid: post-100, type: post, blockHeight: 100 } # dev:post-400 { room: dev, txid: post-400, type: post, blockHeight: 400 }
# lone:addr-f { room: lone, addr: addr-f, type: follow, unfollow: false } # lone:addr-f { room: lone, addr: addr-f, type: follow, unfollow: false }
# posts store: # posts store:
# post-300 { txid: post-300, addr: addr-a, text: hello bitcoin, blockHeight: 300 } # post-300 { txid: post-300, addr: addr-a, text: hello bitcoin, blockHeight: 300 }
# post-200 { txid: post-200, addr: addr-b, text: bitcoin again, blockHeight: 200 } # post-200 { txid: post-200, addr: addr-b, text: bitcoin again, blockHeight: 200 }
# post-250 { txid: post-250, addr: addr-a, text: cash rules, blockHeight: 250 } # post-250 { txid: post-250, addr: addr-a, text: cash rules, blockHeight: 250 }
# post-100 { txid: post-100, addr: addr-c, text: dev stuff, blockHeight: 100 } # post-400 { txid: post-400, addr: addr-c, text: dev stuff, blockHeight: 400 }
Feature: Topic Read Feature: Topic Read
Background: Background:
@@ -63,3 +63,11 @@ Feature: Topic Read
Scenario: Topic Read - 4 GET /topics/:room/posts returns no posts for a topic with no posts Scenario: Topic Read - 4 GET /topics/:room/posts returns no posts for a topic with no posts
When the client requests /topics/lone/posts When the client requests /topics/lone/posts
Then the response contains no posts Then the response contains no posts
Scenario Outline: Topic Read - 5 GET /topics orders topics by most recent post
When the client requests /topics
Then the response lists topics in order <expected_order>
Examples:
| expected_order |
| dev,bitcoin,cash,lone |
+17 -7
View File
@@ -42,21 +42,31 @@ class TopicQuery {
} }
async listTopics () { async listTopics () {
const counts = new Map() const topics = new Map()
for await (const [key, value] of this.roomsDb.iterator()) { for await (const [key, value] of this.roomsDb.iterator()) {
const room = this.roomFromKey(key, value) const room = this.roomFromKey(key, value)
if (!counts.has(room)) { if (!topics.has(room)) {
counts.set(room, 0) topics.set(room, { postCount: 0, lastHeight: 0 })
} }
const topic = topics.get(room)
if (value?.type === 'post') { if (value?.type === 'post') {
counts.set(room, counts.get(room) + 1) topic.postCount++
const height = value?.blockHeight ?? 0
if (height > topic.lastHeight) {
topic.lastHeight = height
}
} }
} }
return Array.from(counts.entries()) return Array.from(topics.entries())
.map(([room, postCount]) => ({ room, postCount })) .map(([room, { postCount, lastHeight }]) => ({ room, postCount, lastHeight }))
.sort((a, b) => a.room.localeCompare(b.room)) .sort((a, b) => {
if (b.lastHeight !== a.lastHeight) {
return b.lastHeight - a.lastHeight
}
return a.room.localeCompare(b.room)
})
} }
async getTopicPostTxids (room, { limit, offset }) { async getTopicPostTxids (room, { limit, offset }) {
@@ -86,7 +86,7 @@ function fixtureGen () {
} }
} }
test('listTopics conserves post counts and returns rooms sorted by name', 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, rooms }) => {
@@ -97,8 +97,18 @@ test('listTopics conserves post counts and returns rooms sorted by name', async
const totalPosts = topics.reduce((sum, t) => sum + t.postCount, 0) const totalPosts = topics.reduce((sum, t) => sum + t.postCount, 0)
if (totalPosts !== postEntries.length) return false if (totalPosts !== postEntries.length) return false
const expectedRooms = [...new Set(entries.map((e) => e.value.room))].sort((a, b) => a.localeCompare(b)) const expectedTopics = [...new Set(entries.map((e) => e.value.room))]
if (JSON.stringify(topics.map((t) => t.room)) !== JSON.stringify(expectedRooms)) return false .map((room) => {
const heights = entries
.filter((e) => e.value.room === room && e.value.type === 'post')
.map((e) => e.value.blockHeight ?? 0)
return { room, lastHeight: heights.length ? Math.max(...heights) : 0 }
})
.sort((a, b) => {
if (b.lastHeight !== a.lastHeight) return b.lastHeight - a.lastHeight
return a.room.localeCompare(b.room)
})
if (JSON.stringify(topics.map((t) => t.room)) !== JSON.stringify(expectedTopics.map((t) => t.room))) 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
@@ -108,23 +108,26 @@ describe('#TopicQuery', () => {
const result = await uut.listTopics() const result = await uut.listTopics()
assert.deepEqual(result, [ assert.deepEqual(result, [
{ room: 'bitcoin', postCount: 2 }, { room: 'bitcoin', postCount: 2, lastHeight: 300 },
{ room: 'cash', postCount: 1 }, { room: 'cash', postCount: 1, lastHeight: 250 },
{ room: 'dev', postCount: 1 }, { room: 'dev', postCount: 1, lastHeight: 100 },
{ room: 'lone', postCount: 0 } { room: 'lone', postCount: 0, lastHeight: 0 }
]) ])
}) })
it('should sort topics by room name', async () => { it('should sort topics by most recent post descending', async () => {
async function * mockRooms () { async function * mockRooms () {
yield ['zoo:post-1', { room: 'zoo', txid: 'post-1', type: 'post', blockHeight: 1 }] yield ['zoo:post-1', { room: 'zoo', txid: 'post-1', type: 'post', blockHeight: 1 }]
yield ['alpha:post-1', { room: 'alpha', txid: 'post-1', type: 'post', blockHeight: 1 }] yield ['alpha:post-1', { room: 'alpha', txid: 'post-1', type: 'post', blockHeight: 2 }]
} }
roomsDb.iterator.returns(mockRooms()) roomsDb.iterator.returns(mockRooms())
const result = await uut.listTopics() const result = await uut.listTopics()
assert.deepEqual(result.map((t) => t.room), ['alpha', 'zoo']) assert.deepEqual(result.map((t) => ({ room: t.room, lastHeight: t.lastHeight })), [
{ room: 'alpha', lastHeight: 2 },
{ room: 'zoo', lastHeight: 1 }
])
}) })
}) })