Spec topic recency ordering and pagination

Add Gherkin for efficient GET /topics ordering by most recent post, the
topicSummaries and topicRecency index stores, an idempotent backfill, and
client topics-page pagination.

By specifier.
This commit is contained in:
Chris Troutner
2026-09-17 06:46:43 -07:00
parent a4b0e080a3
commit e2c0bbe523
6 changed files with 228 additions and 2 deletions
@@ -0,0 +1,27 @@
# Scenarios: Topic Pagination - 1, Topic Pagination - 2
#
# The topics page loads topics in pages of 50 and can move to a later page. The
# page reports whether more topics are available.
Feature: Topic Pagination
Scenario Outline: Topic Pagination - 1 the topics page loads 50 topics per page
Given the psf-memo-db API serves <count> topics
When I open the topics page
Then the topics page shows <shown> topics
And the topics page can load more topics
Examples:
| count | shown |
| 60 | 50 |
| 70 | 50 |
Scenario Outline: Topic Pagination - 2 the topics page can load a later page
Given the psf-memo-db API serves <count> topics
When I open the topics page at offset <offset>
Then the topics page shows <shown> topics
And the topics page has no more topics
Examples:
| count | offset | shown |
| 60 | 50 | 10 |
| 70 | 50 | 20 |
@@ -0,0 +1,51 @@
# Scenarios: Backfill topic indexes - 1, Backfill topic indexes - 2, Backfill topic indexes - 3
#
# The topic backfill utility builds topicSummaries and topicRecency from the
# existing rooms store so the read side can serve /topics by recency without
# scanning posts. Rooms with posts get postCount and lastHeight; follow-only
# rooms get postCount 0 and lastHeight 0. Running the backfill twice is
# idempotent.
#
# Fixture "rooms-with-topics-and-follows":
# rooms store:
# 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 }
Feature: Backfill topic indexes
Background:
Given a psf-memo-db instance with rooms, topicSummaries, and topicRecency stores
Given the fixture "rooms-with-topics-and-follows" is loaded into the rooms store
Scenario Outline: Backfill topic indexes - 1 backfill summarizes every room
When the topic backfill utility is run
Then the topicSummaries store contains the room "<room>" with postCount <postCount> and lastHeight <height>
Examples:
| room | postCount | height |
| bitcoin | 2 | 600200 |
| cash | 1 | 600250 |
| lone | 0 | 0 |
Scenario Outline: Backfill topic indexes - 2 backfill builds the recency index
When the topic backfill utility is run
Then the topicRecency store records the room "<room>" at block height <height>
Examples:
| room | height |
| bitcoin | 600200 |
| cash | 600250 |
| lone | 0 |
Scenario Outline: Backfill topic indexes - 3 backfill is idempotent
When the topic backfill utility is run
And the topic backfill utility is run again
Then the topicSummaries store contains the room "<room>" with postCount <postCount> and lastHeight <height>
And the topicRecency store records the room "<room>" at block height <height>
Examples:
| room | postCount | height |
| bitcoin | 2 | 600200 |
| cash | 1 | 600250 |
@@ -0,0 +1,60 @@
# Scenarios: Topic Pagination - 1, Topic Pagination - 2, Topic Pagination - 3
#
# GET /topics returns a page of topics ordered by their most recent post's
# block height descending, with rooms at the same height ordered by room name
# ascending; rooms with no posts (follow-only) come last, also by room name
# ascending. Pagination metadata reports the total distinct topics and whether
# more remain. The page is served from topicSummaries and topicRecency without
# iterating the rooms store.
#
# Fixture "topic-indexes":
# topicSummaries store:
# memo { room: memo, postCount: 5, lastHeight: 600500 }
# cash { room: cash, postCount: 2, lastHeight: 600400 }
# dance { room: dance, postCount: 3, lastHeight: 600400 }
# anime { room: anime, postCount: 1, lastHeight: 600300 }
# lone { room: lone, postCount: 0, lastHeight: 0 }
# quiet { room: quiet, postCount: 0, lastHeight: 0 }
# topicRecency store:
# memo at 600500, cash at 600400, dance at 600400,
# anime at 600300, lone at 0, quiet at 0
Feature: Topic Pagination
Background:
Given a psf-memo-db instance with topicSummaries and topicRecency stores
Given the fixture "topic-indexes" is loaded into the topic index stores
Scenario Outline: Topic Pagination - 1 GET /topics returns topics ordered by most recent post
When the client requests /topics with limit <limit> and offset <offset>
Then the response lists topics in order <expected_topics>
And the response pagination shows total <total> and hasMore <hasMore>
Examples:
| limit | offset | expected_topics | total | hasMore |
| 2 | 0 | memo,cash | 6 | true |
| 2 | 2 | dance,anime | 6 | true |
| 2 | 4 | lone,quiet | 6 | false |
| 6 | 0 | memo,cash,dance,anime,lone,quiet | 6 | false |
Scenario Outline: Topic Pagination - 2 GET /topics reports each topic's post count
When the client requests /topics with limit <limit> and offset <offset>
Then the response contains the topic "<topic>" with post count <postCount>
Examples:
| limit | offset | topic | postCount |
| 3 | 0 | memo | 5 |
| 3 | 0 | cash | 2 |
| 3 | 3 | anime | 1 |
| 3 | 3 | lone | 0 |
Scenario Outline: Topic Pagination - 3 GET /topics reads the recency index without scanning rooms
When the client requests /topics with limit <limit> and offset <offset>
Then the topicRecency store was read exactly <read_count> records
And the rooms store was not iterated
Examples:
| limit | offset | read_count |
| 2 | 0 | 2 |
| 2 | 2 | 4 |
| 2 | 4 | 6 |
| 6 | 0 | 6 |
+9 -2
View File
@@ -22,11 +22,18 @@
# 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-400 { txid: post-400, addr: addr-c, text: dev stuff, blockHeight: 400 }
# topicSummaries store (derived from rooms):
# bitcoin { room: bitcoin, postCount: 2, lastHeight: 300 }
# cash { room: cash, postCount: 1, lastHeight: 250 }
# dev { room: dev, postCount: 1, lastHeight: 400 }
# lone { room: lone, postCount: 0, lastHeight: 0 }
# topicRecency store (derived from rooms):
# bitcoin at 300, cash at 250, dev at 400, lone at 0
Feature: Topic Read
Background:
Given a psf-memo-db instance with a rooms store and a posts store
Given the fixture "topics-with-posts" is loaded into the rooms and posts stores
Given a psf-memo-db instance with rooms, posts, topicSummaries, and topicRecency stores
Given the fixture "topics-with-posts" is loaded into the rooms, posts, topicSummaries, and topicRecency stores
Scenario Outline: Topic Read - 1 GET /topics lists distinct topics with their post counts
When the client requests /topics
@@ -0,0 +1,65 @@
# Scenarios: Topic Recency Indexing - 1, Topic Recency Indexing - 2, Topic Recency Indexing - 3, Topic Recency Indexing - 4, Topic Recency Indexing - 5
#
# The indexer maintains two topic indexes so the read side can list topics by
# most recent post without scanning every topic post record:
# - topicSummaries: one record per room with postCount and lastHeight.
# - topicRecency: one record per room at that room's most recent post height;
# a room with no posts (follow-only) is recorded at height 0.
# Reprocessing a topic message must not double-count posts, and a follow for a
# room that already has posts must not change the room's summary.
Feature: Topic Recency Indexing
Background:
Given a psf-memo-db instance with rooms, topicSummaries, and topicRecency stores
Given a psf-memo-indexer configured to write to that database
Scenario Outline: Topic Recency Indexing - 1 a topic message records a room summary and a recency record
When the indexer processes a Memo topic message <txid> in room "<room>" from <addr> at block height <height> with text "<text>"
Then the topicSummaries store contains the room "<room>" with postCount 1 and lastHeight <height>
And the topicRecency store records the room "<room>" at block height <height>
Examples:
| txid | room | addr | height | text |
| topic-a1 | bitcoin | bitcoincash:qaddr-a | 600100 | hello |
| topic-b1 | cash | bitcoincash:qaddr-b | 600200 | cash chat |
Scenario Outline: Topic Recency Indexing - 2 successive topic messages accumulate postCount and keep the newest height
When the indexer processes a Memo topic message <firstTxid> in room "<room>" from <addr> at block height <firstHeight> with text "<firstText>"
And the indexer processes a Memo topic message <secondTxid> in room "<room>" from <addr> at block height <secondHeight> with text "<secondText>"
Then the topicSummaries store contains the room "<room>" with postCount 2 and lastHeight <height>
And the topicRecency store records the room "<room>" at block height <height>
Examples:
| firstTxid | secondTxid | room | addr | firstHeight | secondHeight | firstText | secondText | height |
| topic-a1 | topic-a2 | bitcoin | bitcoincash:qaddr-a | 600100 | 600200 | hello | again | 600200 |
| topic-a3 | topic-a4 | bitcoin | bitcoincash:qaddr-a | 600200 | 600100 | later | earlier | 600200 |
Scenario Outline: Topic Recency Indexing - 3 reprocessing a topic message is idempotent
When the indexer processes a Memo topic message <txid> in room "<room>" from <addr> at block height <height> with text "<text>"
And the indexer processes the same Memo topic message <txid> again
Then the topicSummaries store contains the room "<room>" with postCount 1 and lastHeight <height>
And the topicRecency store records the room "<room>" at block height <height>
Examples:
| txid | room | addr | height | text |
| topic-c1 | bitcoin | bitcoincash:qaddr-c | 600300 | repeated |
Scenario Outline: Topic Recency Indexing - 4 a topic follow with no posts records a zero-post room
When the indexer processes a Memo topic follow for room "<room>" from <addr>
Then the topicSummaries store contains the room "<room>" with postCount 0 and lastHeight 0
And the topicRecency store records the room "<room>" at block height 0
Examples:
| room | addr |
| lone | bitcoincash:qaddr-a |
| dev | bitcoincash:qaddr-b |
Scenario Outline: Topic Recency Indexing - 5 a topic follow for a room with posts leaves its summary unchanged
When the indexer processes a Memo topic message <txid> in room "<room>" from <addr> at block height <height> with text "<text>"
And the indexer processes a Memo topic follow for room "<room>" from <addr>
Then the topicSummaries store contains the room "<room>" with postCount 1 and lastHeight <height>
And the topicRecency store records the room "<room>" at block height <height>
Examples:
| txid | room | addr | height | text |
| topic-d1 | bitcoin | bitcoincash:qaddr-a | 600400 | followed |
+16
View File
@@ -29,6 +29,22 @@ focus is **front-end improvements** to `psf-memo-client` (the React SPA).
- A single user-facing feature may still touch more than one component; call
out all affected components in the task description and in the handoff.
## In progress
- **Topic recency ordering and pagination (task `topic-recency-pagination`):**
make `GET /topics` order by most recent topic post without scanning the whole
`rooms` store, and paginate it. The indexer maintains two new stores:
`topicSummaries` (one record per room with `postCount` and `lastHeight`) and
`topicRecency` (one record per room at its latest post height; follow-only
rooms at height 0). `GET /topics` gains `limit`/`offset` and returns
`pagination`; the client topics page loads 50 per page with Previous/Next.
A topic backfill utility builds both indexes from the existing `rooms` store.
Client + indexer + DB. Specs:
`psf-memo-indexer/specs/topic-recency-indexing.feature`,
`psf-memo-db/specs/backfill-topic-indexes.feature`,
`psf-memo-db/specs/topic-pagination.feature`,
`psf-memo-client/specs/topic-pagination.feature`.
## Recently completed
- **Memo multi-push encoding (2026-09-17):** fixed the payload layout for