Merge commit '8ee07fc35f' into swarmforge-refactorer

This commit is contained in:
Chris Troutner
2026-09-02 12:53:28 -07:00
23 changed files with 1453 additions and 8 deletions
+67
View File
@@ -0,0 +1,67 @@
# Architect Process Notes
> **ROLE-SCOPED — ARCHITECT ONLY. DO NOT FOLLOW.**
>
> This file is the **architect role's private working notes**. It records
> process exceptions, tooling behavior, and observations specific to how the
> architect runs its workflow. It is **not** shared guidance and is **not**
> intended for the specifier, coder, or refactorer roles. If you are not the
> architect, **ignore this file entirely** — do not treat anything here as a
> directive, convention, or requirement for your own role. Your role's
> instructions come only from your own role prompt and the constitution.
Durable notes on process exceptions, tooling behavior, and recurring
observations discovered while running the architect workflow. These are
process-level notes (how the tools behave, what to expect, what to watch for),
distinct from per-task verification results, which live in
`docs/reviews/<task>-summary.md`.
## Tooling behavior / runtime
- **Mutation runs dominate wall-clock time.** Each `mutate4javascript <file>`
invocation runs the **full test suite as a baseline** (coverage refresh) before
running mutations, then runs mutations in parallel with `--max-workers 8`.
Because the baseline re-runs the whole suite, mutating N files costs roughly
N full-suite runs. Plan for this: batch the affected files, run them
sequentially, and use `--max-workers 8` to keep the mutation phase fast.
The DRY and soft-Gherkin-mutation steps are comparatively quick.
- **`memo-db.js` (client HTTP adapter) is excluded from mutation testing.** It
uses ESM + a directory import (`../config`) that is only resolvable via
react-scripts/webpack, so it cannot be loaded under plain `node --test`. Its
read behavior is exercised end-to-end via the DB acceptance tests. This is a
standing precedent (also applied to the search task); do not attempt to force
mutation coverage on it.
- **Soft Gherkin acceptance mutation survivors are usually genuine
equivalents.** For read-only features, single-character case mutations of
example values (addresses, text, txids) survive because each example value is
used consistently on both the setup and assertion sides of its scenario.
These are intrinsic equivalents, not implementation gaps; document them in the
review summary and do not chase them.
- **DRY reports pre-existing pattern-boilerplate.** The layered conventions
(follow/mute/poll controllers, route-registration `index.js`, memo-follow/
memo-mute services) produce score-1.00 duplicates that prior reviews left
as-is. A shared controller base would be a broad cross-module refactor beyond
any single handoff. Only reduce duplication that is local to the task at hand.
## Workflow observations
- **Review summaries must be force-added.** `docs/` is in the root `.gitignore`,
so `git add -A` silently skips `docs/reviews/<task>-summary.md`. The role
requires the summary to be committed with the byline in the same commit as the
review changes, so use `git add -f docs/reviews/<task>-summary.md` (or
`git add -f docs/process-notes.md`) before committing. This has silently
dropped 8 of 13 summaries in the past; verify with `git ls-files docs/reviews/`
after committing.
- **`ready_for_next.sh` / `done_with_current.sh`** are the source of truth for
queued work. `done_with_current.sh` prints `NO_TASK` when the queue is empty;
stop waiting for work in that case.
- **Handoff `commit` field must be exactly 10 hex chars.** `swarm_handoff.sh`
rejects shorter abbreviations; use `git rev-parse --short=10 HEAD`.
- **Run per-component verification for every component a task touches** before
handing off (client, db, indexer), per the monorepo rules.
+86
View File
@@ -0,0 +1,86 @@
# following-feed — Architect Review Summary
**By architect.**
## Task and commits reviewed
- Task: `following-feed` (refactorer handoff, `merge_and_process refactorer d606ee6608`)
- Merged `swarmforge-refactorer` (fast-forward) — commits:
- `6d94372` Spec Following feed (P6.6) (specifier)
- `6d1bd14` Implement Following feed (coder)
- `d606ee6` Refactor following feed: reduce CRAP/DRY, add property coverage (refactorer)
## Architectural findings and fixes
- **UI/Core separation (confirmed good):** Client `FollowingFeedPage` is a thin, testable
controller wrapping the `MemoDb` HTTP client; the React `FollowingFeed` component stays a pure
UI shell. The feature is read-only and needs no wallet broadcast. Core behavior is testable
without UI or network I/O.
- **Dependency rule (confirmed good):** DB `ListFollowingFeed` extends the shared `ListUseCase`
base and depends only on the `followQuery` and `postQuery` adapter interfaces; the `/posts`
REST controller is a thin adapter. The refactorer's `runUseCase`/`listPostsForAddr` extraction
removed the duplicated try/catch error wrapper across the addr-scoped handlers.
- **Information hiding (confirmed good):** `PostQuery.scanFollowingFeedTxidsAndCount` encapsulates
the LevelDB key/iterator model and the follows-join; the controller hides the query/limit/offset
wiring; the client `MemoDb.getFollowingFeed` hides the HTTP endpoint. No framework or persistence
structures leak across boundaries.
- **DRY reductions applied (behavior-preserving):** extracted `parseRequiredString` into
`src/use-cases/lib/pagination.js` and used it in `ListFollowingFeed`, `ListPostsByAddr`, and
`ListTopicPosts`, removing the three duplicated `parseAddr`/`parseRoom` required-field guards.
## Test hardening applied (kill mutation survivors / cover uncovered)
- **psf-memo-client `following-feed-page.test.js`** (covered 2 previously-uncovered sites):
- constructor starts with `emptyBecauseNoFollows` false — covers the constructor field init;
- `canLoadMore` is false when pagination is absent — covers the `canLoadMore` null-guard.
- **psf-memo-db `use-cases/index.unit.js`** (new): composition-root test asserting `start()`
instantiates every use case (including `listFollowingFeed`) and that the constructor rejects
missing adapters — covers the previously-uncovered `UseCases.start` wiring.
No production behavior changed; the only non-test source diffs are tool-generated mutation
manifests and the Gherkin acceptance-mutation stamp.
## Verification results
### Language mutation (`mutate4javascript`, differential vs manifest)
- **psf-memo-db** — all affected files **0 killed / 0 survived / 0 uncovered**:
`list-following-feed.js`, `list-posts-by-addr.js`, `list-topic-posts.js`, `lib/pagination.js`,
`use-cases/index.js`, `adapters/post-query.js`, `controllers/rest-api/posts/controller.js`,
`controllers/rest-api/posts/index.js`.
- **psf-memo-client**
- `following-feed-page.js` **0 killed / 0 survived / 0 uncovered**.
- `memo-db.js` excluded: HTTP adapter using ESM + directory import (`../config`) only
resolvable via react-scripts/webpack, not loadable under plain `node --test`; its read
behavior is exercised end-to-end via the DB `/posts/following` acceptance. Consistent with
the search precedent.
### DRY (`dry4javascript`)
- The `parseRequiredString` extraction removed the three duplicated required-field guards.
- Remaining duplicates are pre-existing pattern-boilerplate inherent to the established layered
conventions (follow/mute/poll controllers, route-registration `index.js`, memo-follow/memo-mute
services) and test/acceptance helpers — left as-is, consistent with prior reviews.
### CRAP / cyclomatic complexity (`crap4javascript`)
All following-feed functions well below the 8.0 threshold — highest is `FollowingFeedPage.load`
(CC 6, 100% cov, CRAP 6.0) and `parseRequiredString` (CC 3, 100% cov, CRAP 3.0); the rest are
CC 12 with 100% coverage.
### Soft Gherkin acceptance mutation (`gherkin-mutator --level soft`)
- **psf-memo-client** `following-feed.feature`: 12 executed, **0 killed, 12 survived** — all
survivors are single-character case mutations of the example values (addresses, text, txids).
Each example value is used consistently on both the setup and assertion sides of its scenario,
so the mutation yields the same result set; the mutations are genuine equivalents.
Specifier-side feature-quality item.
No implementation changes are warranted for the soft-mutation survivors; they are intrinsic
equivalents.
## Suite status
- **psf-memo-client**: unit **229 passing** (was 227; +2 hardening tests), property **30 passing**,
acceptance **19 generated files, all passing**, lint **clean**, build **pass**.
- **psf-memo-db**: unit **281 passing** (was 280; +1 new composition-root test), property **37
passing**, acceptance **9 generated files, all passing**, lint **clean**.
- **psf-memo-indexer**: unit **85 passing** (untouched by this task).
## Handoffs sent
- `git_handoff` to coder and refactorer (`priority: 00`) with the review commit (test hardening +
refreshed mutation manifests + Gherkin acceptance-mutation stamp) for follow-up review.
- No specifier handoff: no functional or spec change in this commit (only test hardening and
tool-generated manifests/stamps).
+237 -2
View File
@@ -41,6 +41,7 @@ const ThreadPage = require('../../src/services/thread-page')
const TopicDiscoveryPage = require('../../src/services/topic-discovery-page')
const TopicFeedPage = require('../../src/services/topic-feed-page')
const SearchPage = require('../../src/services/search-page')
const NotificationsPage = require('../../src/services/notifications-page')
const MemoTopicFollow = require('../../src/services/memo-topic-follow')
const MemoTopicPost = require('../../src/services/memo-topic-post')
const TopicPostPage = require('../../src/services/topic-post-page')
@@ -190,6 +191,9 @@ function makeMemoDb () {
const followState = {}
const muteState = {}
const replyTxids = new Set()
const replies = []
const likes = []
const followers = new Map()
const topics = []
const topicPosts = {}
const topicCounts = new Map()
@@ -210,7 +214,20 @@ function makeMemoDb () {
},
addReply (reply) {
replyTxids.add(reply.txid)
posts.push(reply)
replies.push({
txid: reply.txid,
parentTxid: reply.parentTxid,
text: reply.text,
addr: reply.addr || 'bitcoincash:reply-author',
blockHeight: reply.blockHeight ?? 100
})
posts.push({
txid: reply.txid,
addr: reply.addr || 'bitcoincash:reply-author',
text: reply.text,
blockHeight: reply.blockHeight ?? 100,
seen: reply.seen ?? 0
})
},
addSearchPost (post) {
searchPosts.push(post)
@@ -218,6 +235,24 @@ function makeMemoDb () {
addSearchProfile (profile) {
searchProfiles.push(profile)
},
addLike (like) {
likes.push({
txid: like.txid,
postTxid: like.postTxid,
addr: like.addr,
blockHeight: like.blockHeight ?? 100
})
},
addFollower (followerAddr, followeeAddr, opts = {}) {
const list = followers.get(followeeAddr) || []
list.push({
followerAddr,
followeeAddr,
txid: opts.txid || require('crypto').createHash('sha256').update(`${followerAddr}:${followeeAddr}`).digest('hex'),
blockHeight: opts.blockHeight ?? 100
})
followers.set(followeeAddr, list)
},
addTopic (room, postCount) {
topicCounts.set(room, postCount)
topicPosts[room] = []
@@ -309,6 +344,52 @@ function makeMemoDb () {
const page = all.slice(offset, offset + limit)
return { posts: page, pagination: { total: all.length, limit, offset, hasMore: offset + page.length < all.length } }
},
async getNotifications (addr, { limit = 100, offset = 0 } = {}) {
const notifications = []
for (const reply of replies) {
const parent = posts.find((p) => p.txid === reply.parentTxid)
if (!parent || parent.addr !== addr) continue
if (reply.addr === addr) continue
notifications.push({
type: 'reply',
txid: reply.txid,
addr: reply.addr,
postTxid: reply.parentTxid,
text: reply.text,
blockHeight: reply.blockHeight ?? parent.blockHeight ?? 0
})
}
for (const like of likes) {
const post = posts.find((p) => p.txid === like.postTxid)
if (!post || post.addr !== addr) continue
if (like.addr === addr) continue
notifications.push({
type: 'like',
txid: like.txid,
addr: like.addr,
postTxid: like.postTxid,
blockHeight: like.blockHeight ?? post.blockHeight ?? 0
})
}
for (const follow of (followers.get(addr) || [])) {
if (follow.followerAddr === addr) continue
notifications.push({
type: 'follow',
txid: follow.txid,
addr: follow.followerAddr,
blockHeight: follow.blockHeight ?? 0
})
}
notifications.sort((a, b) => (b.blockHeight ?? 0) - (a.blockHeight ?? 0))
const total = notifications.length
const page = notifications.slice(offset, offset + limit)
return { notifications: page, pagination: { total, limit, offset, hasMore: offset + page.length < total } }
},
async getFollowingFeed (addr, { limit = 100, offset = 0 } = {}) {
const followees = new Set()
for (const [key, following] of Object.entries(followState)) {
@@ -362,6 +443,7 @@ function createWorld () {
// Read-only page controllers backed by the fake psf-memo-db API.
world.recentFeedPage = new RecentFeedPage({ memoDb })
world.followingFeedPage = new FollowingFeedPage({ memoDb, wallet })
world.notificationsPage = new NotificationsPage({ memoDb, wallet })
world.profilePage = new ProfilePage({ memoDb })
world.threadPage = new ThreadPage({ memoDb })
world.topicDiscoveryPage = new TopicDiscoveryPage({
@@ -2276,7 +2358,7 @@ const handlers = [
},
{
name: 'API serves reply with txid parent and text',
pattern: /^the psf-memo-db API serves a reply with txid (.+) to the post with txid (.+) with text (.+)$/,
pattern: /^the psf-memo-db API serves a reply with txid ([0-9a-fA-F]{64}) to the post with txid ([0-9a-fA-F]{64}) with text (.+)$/,
run (m, example, world) {
const txid = resolveParam(m[1], example)
const parentTxid = resolveParam(m[2], example)
@@ -2392,6 +2474,159 @@ const handlers = [
throw new Error('Expected following feed to show the not-following-anyone message.')
}
}
},
{
name: 'API serves reply to my post by address with text',
pattern: /^the psf-memo-db API serves a reply with txid (.+) to the post with txid (.+) by the address (.+) with text (.+?)(?: at block height (\d+))?$/,
run (m, example, world) {
const txid = resolveParam(m[1], example)
const parentTxid = resolveParam(m[2], example)
const addr = resolveParam(m[3], example)
const text = resolveText(m[4], example)
const blockHeight = m[5] ? parseInt(m[5], 10) : 100
world.memoDb.addReply({ txid, parentTxid, text, addr, blockHeight })
}
},
{
name: 'API serves reply to my post by me with text',
pattern: /^the psf-memo-db API serves a reply with txid (.+) to the post with txid (.+) by my wallet address with text (.+?)(?: at block height (\d+))?$/,
run (m, example, world) {
const txid = resolveParam(m[1], example)
const parentTxid = resolveParam(m[2], example)
const text = resolveText(m[3], example)
const blockHeight = m[4] ? parseInt(m[4], 10) : 100
const myAddr = world.wallet.walletInfo.cashAddress
world.memoDb.addReply({ txid, parentTxid, text, addr: myAddr, blockHeight })
}
},
{
name: 'API serves like on my post by address',
pattern: /^the psf-memo-db API serves a like with txid (.+) on the post with txid (.+) by the address (.+?)(?: at block height (\d+))?$/,
run (m, example, world) {
const txid = resolveParam(m[1], example)
const postTxid = resolveParam(m[2], example)
const addr = resolveParam(m[3], example)
const blockHeight = m[4] ? parseInt(m[4], 10) : 100
world.memoDb.addLike({ txid, postTxid, addr, blockHeight })
}
},
{
name: 'API records address follows me',
pattern: /^the psf-memo-db API records that the address (.+) follows my wallet address$/,
run (m, example, world) {
const followerAddr = resolveParam(m[1], example)
const myAddr = world.wallet.walletInfo.cashAddress
world.memoDb.addFollower(followerAddr, myAddr)
}
},
{
name: 'open notifications page',
pattern: /^I open the Notifications page$/,
async run (m, example, world) {
await world.notificationsPage.load()
world.currentPath = NotificationsPage.NOTIFICATIONS_PATH
}
},
{
name: 'open notifications page with page size',
pattern: /^I open the Notifications page with page size (\d+)$/,
async run (m, example, world) {
const limit = parseInt(m[1], 10)
await world.notificationsPage.load({ limit })
world.currentPath = NotificationsPage.NOTIFICATIONS_PATH
}
},
{
name: 'notifications include reply notification',
pattern: /^the notifications include a reply notification from the address (.+) with text (.+)$/,
run (m, example, world) {
const expectedAddr = resolveParam(m[1], example)
const expectedText = resolveText(m[2], example)
const found = world.notificationsPage.notifications.find((n) =>
n.type === 'reply' && n.addr === expectedAddr && n.text === expectedText
)
if (!found) {
throw new Error(`Notifications do not include a reply from ${expectedAddr} with text "${expectedText}".`)
}
}
},
{
name: 'notifications include like notification',
pattern: /^the notifications include a like notification from the address (.+)$/,
run (m, example, world) {
const expectedAddr = resolveParam(m[1], example)
const found = world.notificationsPage.notifications.find((n) =>
n.type === 'like' && n.addr === expectedAddr
)
if (!found) {
throw new Error(`Notifications do not include a like from ${expectedAddr}.`)
}
}
},
{
name: 'notifications include follow notification',
pattern: /^the notifications include a follow notification from the address (.+)$/,
run (m, example, world) {
const expectedAddr = resolveParam(m[1], example)
const found = world.notificationsPage.notifications.find((n) =>
n.type === 'follow' && n.addr === expectedAddr
)
if (!found) {
throw new Error(`Notifications do not include a follow from ${expectedAddr}.`)
}
}
},
{
name: 'notifications show like before reply',
pattern: /^the notifications show the like notification before the reply notification$/,
run (m, example, world) {
const notifications = world.notificationsPage.notifications
const likeIndex = notifications.findIndex((n) => n.type === 'like')
const replyIndex = notifications.findIndex((n) => n.type === 'reply')
if (likeIndex === -1) throw new Error('Notifications do not include a like notification.')
if (replyIndex === -1) throw new Error('Notifications do not include a reply notification.')
if (likeIndex >= replyIndex) {
throw new Error('Expected like notification to appear before reply notification.')
}
}
},
{
name: 'notifications show N notifications',
pattern: /^the notifications show (\d+) notification$/,
run (m, example, world) {
const expected = parseInt(m[1], 10)
const actual = world.notificationsPage.notifications.length
if (actual !== expected) {
throw new Error(`Expected ${expected} notifications, got ${actual}.`)
}
}
},
{
name: 'notifications can load more',
pattern: /^the notifications can load more$/,
run (m, example, world) {
if (!world.notificationsPage.canLoadMore()) {
throw new Error('Expected notifications to have more pages, but pagination says there are none.')
}
}
},
{
name: 'notifications include no notifications',
pattern: /^the notifications include no notifications$/,
run (m, example, world) {
if (world.notificationsPage.notifications.length !== 0) {
throw new Error(`Expected no notifications, got ${world.notificationsPage.notifications.length}.`)
}
}
},
{
name: 'notifications show no notifications message',
pattern: /^the notifications show a message that I have no notifications$/,
run (m, example, world) {
if (!world.notificationsPage.empty) {
throw new Error('Expected notifications page to show the no-notifications message.')
}
}
}
]
@@ -0,0 +1,75 @@
# Scenarios: Notifications - 1, Notifications - 2, Notifications - 3, Notifications - 4, Notifications - 5, Notifications - 6, Notifications - 7, Notifications - 8
#
# Notifications is a read-only feature: the psf-memo-db API aggregates replies
# to the viewer's posts, likes on the viewer's posts, and follows of the
# viewer into a single newest-first, paginated list. The client identifies the
# viewer from the wallet and renders the page; it broadcasts no Memo action.
# Only actions by other people on the viewer's own content are shown; the
# viewer's own actions never notify them.
Feature: Notifications
Background:
Given a wallet authenticated for the address bitcoincash:qqlrzp23w08434twtmvr4fxw672whkjy0py26r63g3d
Scenario Outline: Notifications - 1 a reply to my post appears as a reply notification
Given the psf-memo-db API serves a post with txid <my_post> authored by my wallet address with text "hello from me"
Given the psf-memo-db API serves a reply with txid <reply_txid> to the post with txid <my_post> by the address <replier> with text <reply_text>
When I open the Notifications page
Then the notifications include a reply notification from the address <replier> with text <reply_text>
Examples:
| my_post | reply_txid | replier | reply_text |
| 1111111111111111111111111111111111111111111111111111111111111111 | 2222222222222222222222222222222222222222222222222222222222222222 | bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy | nice post |
| 3333333333333333333333333333333333333333333333333333333333333333 | 4444444444444444444444444444444444444444444444444444444444444444 | bitcoincash:qqq3728yw0y47sqn6l2na30mcw6zm78dzqre909m2r | agreed |
Scenario Outline: Notifications - 2 a like on my post appears as a like notification
Given the psf-memo-db API serves a post with txid <my_post> authored by my wallet address with text "hello from me"
Given the psf-memo-db API serves a like with txid <like_txid> on the post with txid <my_post> by the address <liker>
When I open the Notifications page
Then the notifications include a like notification from the address <liker>
Examples:
| my_post | like_txid | liker |
| 1111111111111111111111111111111111111111111111111111111111111111 | 2222222222222222222222222222222222222222222222222222222222222222 | bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy |
| 3333333333333333333333333333333333333333333333333333333333333333 | 4444444444444444444444444444444444444444444444444444444444444444 | bitcoincash:qqq3728yw0y47sqn6l2na30mcw6zm78dzqre909m2r |
Scenario Outline: Notifications - 3 a follow of me appears as a follow notification
Given the psf-memo-db API records that the address <follower> follows my wallet address
When I open the Notifications page
Then the notifications include a follow notification from the address <follower>
Examples:
| follower |
| bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy |
| bitcoincash:qqq3728yw0y47sqn6l2na30mcw6zm78dzqre909m2r |
Scenario: Notifications - 4 notifications are ordered newest first
Given the psf-memo-db API serves a post with txid 1111111111111111111111111111111111111111111111111111111111111111 authored by my wallet address with text "hello from me"
Given the psf-memo-db API serves a reply with txid 2222222222222222222222222222222222222222222222222222222222222222 to the post with txid 1111111111111111111111111111111111111111111111111111111111111111 by the address bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy with text "nice post" at block height 100
Given the psf-memo-db API serves a like with txid 3333333333333333333333333333333333333333333333333333333333333333 on the post with txid 1111111111111111111111111111111111111111111111111111111111111111 by the address bitcoincash:qqq3728yw0y47sqn6l2na30mcw6zm78dzqre909m2r at block height 300
When I open the Notifications page
Then the notifications show the like notification before the reply notification
Scenario: Notifications - 5 notifications paginate
Given the psf-memo-db API serves a post with txid 1111111111111111111111111111111111111111111111111111111111111111 authored by my wallet address with text "hello from me"
Given the psf-memo-db API serves a reply with txid 2222222222222222222222222222222222222222222222222222222222222222 to the post with txid 1111111111111111111111111111111111111111111111111111111111111111 by the address bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy with text "nice post"
Given the psf-memo-db API serves a like with txid 3333333333333333333333333333333333333333333333333333333333333333 on the post with txid 1111111111111111111111111111111111111111111111111111111111111111 by the address bitcoincash:qqq3728yw0y47sqn6l2na30mcw6zm78dzqre909m2r
When I open the Notifications page with page size 1
Then the notifications show 1 notification
And the notifications can load more
Scenario: Notifications - 6 only notifications about my content are shown
Given the psf-memo-db API serves a post with txid 1111111111111111111111111111111111111111111111111111111111111111 authored by the address bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy with text "alice's post"
Given the psf-memo-db API serves a reply with txid 2222222222222222222222222222222222222222222222222222222222222222 to the post with txid 1111111111111111111111111111111111111111111111111111111111111111 by the address bitcoincash:qqq3728yw0y47sqn6l2na30mcw6zm78dzqre909m2r with text "a reply to alice"
When I open the Notifications page
Then the notifications include no notifications
Scenario: Notifications - 7 the page shows a message when I have no notifications
When I open the Notifications page
Then the notifications show a message that I have no notifications
Scenario: Notifications - 8 my own actions do not notify me
Given the psf-memo-db API serves a post with txid 1111111111111111111111111111111111111111111111111111111111111111 authored by my wallet address with text "hello from me"
Given the psf-memo-db API serves a reply with txid 2222222222222222222222222222222222222222222222222222222222222222 to the post with txid 1111111111111111111111111111111111111111111111111111111111111111 by my wallet address with text "my own reply"
When I open the Notifications page
Then the notifications include no notifications
@@ -35,6 +35,7 @@ import Topics from './topics'
import TopicFeed from './topic-feed'
import Search from './search'
import FollowingFeed from './following-feed'
import Notifications from './notifications'
function AppBody (props) {
// Dependency injection through props
@@ -56,6 +57,7 @@ function AppBody (props) {
<Route path='/topics/:room' element={<TopicFeed appData={appData} />} />
<Route path='/search' element={<Search />} />
<Route path='/posts/following' element={<FollowingFeed appData={appData} />} />
<Route path='/notifications' element={<Notifications appData={appData} />} />
<Route path='/memo/set-name' element={<SetName appData={appData} />} />
<Route path='/memo/set-bio' element={<SetBio appData={appData} />} />
<Route path='/memo/set-avatar-url' element={<SetAvatarUrl appData={appData} />} />
@@ -0,0 +1,149 @@
/*
Display the Notifications page: replies to my posts, likes on my posts,
and new follows, newest first.
*/
// Global npm libraries
import React, { useState, useEffect } from 'react'
import { Container, Row, Col, Spinner, Button } from 'react-bootstrap'
// Local libraries
import MemoDb from '../../../services/memo-db'
import NotificationsPage from '../../../services/notifications-page'
import '../../../App.css'
const PAGE_SIZE = 100
function notificationText (n) {
if (n.type === 'reply') {
return `replied to your post: ${n.text || ''}`
}
if (n.type === 'like') {
return 'liked your post'
}
if (n.type === 'follow') {
return 'followed you'
}
return ''
}
function Notifications (props) {
const { appData } = props
const wallet = appData?.wallet
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [notifications, setNotifications] = useState([])
const [pagination, setPagination] = useState(null)
const [offset, setOffset] = useState(0)
useEffect(() => {
const loadNotifications = async () => {
setLoading(true)
setError(null)
try {
const memoDb = new MemoDb()
const page = new NotificationsPage({ memoDb, wallet })
const data = await page.load({ limit: PAGE_SIZE, offset })
setNotifications(data.notifications || [])
setPagination(data.pagination || null)
} catch (err) {
setError(err.message || 'Failed to load notifications')
setNotifications([])
setPagination(null)
}
setLoading(false)
}
loadNotifications()
}, [offset, wallet])
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 (
<Container className='notifications-page'>
<Row className='justify-content-center'>
<Col lg={8} md={10} xs={12}>
<header className='notifications-heading'>
<h1>Notifications</h1>
<p>Replies, likes, and follows involving you.</p>
{pagination && notifications.length > 0 && (
<span className='notifications-count'>
Showing {pagination.offset + 1}
{pagination.offset + notifications.length} of {pagination.total}
</span>
)}
</header>
{error && (
<p className='notifications-error'>
{error}
</p>
)}
{loading && (
<div className='text-center my-5'>
<Spinner animation='border' role='status'>
<span className='visually-hidden'>
Loading...
</span>
</Spinner>
</div>
)}
{!loading && !error && notifications.length === 0 && (
<p className='notifications-empty'>You have no notifications.</p>
)}
{!loading && !error && notifications.length > 0 && (
<div className='notifications-list'>
{notifications.map((n) => (
<div key={n.txid} className='notification-item' style={{ marginBottom: '1rem', padding: '0.75rem', border: '1px solid #dee2e6', borderRadius: '0.375rem' }}>
<p className='text-muted' style={{ fontFamily: 'monospace', marginBottom: '0.25rem' }}>
{n.addr}
</p>
<p style={{ marginBottom: 0 }}>{notificationText(n)}</p>
</div>
))}
</div>
)}
{!loading && !error && (pagination || offset > 0) && (
<div className='notifications-pagination'>
<Button
variant='outline-dark'
onClick={handlePrevious}
disabled={!canGoBack}
>
Previous
</Button>
<Button
variant='outline-dark'
onClick={handleNext}
disabled={!canGoNext}
>
Next
</Button>
</div>
)}
</Col>
</Row>
</Container>
)
}
export default Notifications
@@ -94,6 +94,14 @@ function NavMenu (props) {
Following
</NavLink>
<NavLink
className={currentPath === '/notifications' ? 'nav-link-active' : 'nav-link-inactive'}
to='/notifications'
onClick={handleClickEvent}
>
Notifications
</NavLink>
<NavLink
className={currentPath === '/posts/new' ? 'nav-link-active' : 'nav-link-inactive'}
to='/posts/new'
+4
View File
@@ -157,6 +157,10 @@ class MemoDb {
async getFollowingFeed (addr, opts = {}) {
return this.getPage(`/posts/following/${encodeURIComponent(addr)}`, 'getFollowingFeed', opts)
}
async getNotifications (addr, opts = {}) {
return this.getPage(`/posts/notifications/${encodeURIComponent(addr)}`, 'getNotifications', opts)
}
}
export default MemoDb
@@ -0,0 +1,58 @@
/*
Notifications Page behavior: load and display replies, likes, and follows
that involve the viewer.
This is the testable controller behind the React Notifications page. It
wraps the MemoDb client, identifies the viewer from the injected wallet, and
exposes the loaded notifications so the view can render them.
*/
const NOTIFICATIONS_PATH = '/notifications'
class NotificationsPage {
constructor (deps = {}) {
this.memoDb = deps.memoDb || null
this.wallet = deps.wallet || null
this.notifications = []
this.pagination = null
this.empty = false
}
getMyAddress () {
return this.wallet?.walletInfo?.cashAddress || null
}
async load ({ limit = 100, offset = 0 } = {}) {
if (!this.memoDb) {
throw new Error('Notifications page requires a memo db client.')
}
const myAddr = this.getMyAddress()
if (!myAddr) {
throw new Error('Notifications page requires an authenticated wallet.')
}
const data = await this.memoDb.getNotifications(myAddr, { limit, offset })
this.notifications = data.notifications || []
this.pagination = data.pagination || null
this.empty = this.notifications.length === 0 && offset === 0
return {
notifications: this.notifications,
pagination: this.pagination,
empty: this.empty
}
}
canLoadMore () {
return this.pagination?.hasMore ?? false
}
getNotification (txid) {
return this.notifications.find((n) => n.txid === txid) || null
}
}
NotificationsPage.NOTIFICATIONS_PATH = NOTIFICATIONS_PATH
module.exports = NotificationsPage
@@ -0,0 +1,145 @@
/*
Unit tests for the notifications page controller.
*/
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const NotificationsPage = require('../../src/services/notifications-page')
const MY_ADDRESS = 'bitcoincash:qqlrzp23w08434twtmvr4fxw672whkjy0py26r63g3d'
function makeWallet () {
return {
walletInfo: { cashAddress: MY_ADDRESS }
}
}
function makeMemoDb (notifications, pagination) {
return {
async getNotifications (addr, { limit, offset }) {
return { notifications, pagination }
}
}
}
test('load returns notifications', async () => {
const notifications = [
{ type: 'reply', txid: 'a'.repeat(64), addr: 'bitcoincash:other', text: 'hi' },
{ type: 'like', txid: 'b'.repeat(64), addr: 'bitcoincash:other2' }
]
const page = new NotificationsPage({
memoDb: makeMemoDb(notifications, { total: 2 }),
wallet: makeWallet()
})
const result = await page.load()
assert.deepEqual(result.notifications, notifications)
assert.equal(result.pagination.total, 2)
assert.equal(result.empty, false)
})
test('load marks empty notifications at offset zero', async () => {
const page = new NotificationsPage({
memoDb: makeMemoDb([], { total: 0 }),
wallet: makeWallet()
})
const result = await page.load()
assert.deepEqual(result.notifications, [])
assert.equal(result.empty, true)
})
test('load does not mark empty paginated page as empty', async () => {
const page = new NotificationsPage({
memoDb: makeMemoDb([], { total: 2 }),
wallet: makeWallet()
})
const result = await page.load({ offset: 100 })
assert.equal(result.empty, false)
})
test('load forwards limit and offset to the memo db client', async () => {
const calls = []
const memoDb = {
async getNotifications (addr, params) {
calls.push({ addr, params })
return { notifications: [], pagination: {} }
}
}
const page = new NotificationsPage({ memoDb, wallet: makeWallet() })
await page.load({ limit: 10, offset: 20 })
assert.deepEqual(calls, [{ addr: MY_ADDRESS, params: { limit: 10, offset: 20 } }])
})
test('load defaults limit to 100 and offset to 0', async () => {
const calls = []
const memoDb = {
async getNotifications (addr, params) {
calls.push({ addr, params })
return { notifications: [], pagination: {} }
}
}
const page = new NotificationsPage({ memoDb, wallet: makeWallet() })
await page.load()
assert.deepEqual(calls, [{ addr: MY_ADDRESS, params: { limit: 100, offset: 0 } }])
})
test('load throws when no memo db client is provided', async () => {
const page = new NotificationsPage({ wallet: makeWallet() })
await assert.rejects(
() => page.load(),
/requires a memo db client/
)
})
test('load throws when no wallet is provided', async () => {
const page = new NotificationsPage({ memoDb: makeMemoDb([], {}) })
await assert.rejects(
() => page.load(),
/requires an authenticated wallet/
)
})
test('canLoadMore reflects pagination.hasMore', async () => {
const pageMore = new NotificationsPage({
memoDb: makeMemoDb([], { hasMore: true }),
wallet: makeWallet()
})
await pageMore.load()
assert.equal(pageMore.canLoadMore(), true)
const pageDone = new NotificationsPage({
memoDb: makeMemoDb([], { hasMore: false }),
wallet: makeWallet()
})
await pageDone.load()
assert.equal(pageDone.canLoadMore(), false)
})
test('getNotification returns a loaded notification by txid', async () => {
const notifications = [{ type: 'follow', txid: 'a'.repeat(64), addr: 'bitcoincash:other' }]
const page = new NotificationsPage({
memoDb: makeMemoDb(notifications, {}),
wallet: makeWallet()
})
await page.load()
assert.equal(page.getNotification('a'.repeat(64)).type, 'follow')
})
test('exposes the notifications path', () => {
assert.equal(NotificationsPage.NOTIFICATIONS_PATH, '/notifications')
})
+9
View File
@@ -11,6 +11,7 @@ import MuteQuery from './mute-query.js'
import TopicQuery from './topic-query.js'
import PollQuery from './poll-query.js'
import SearchQuery from './search-query.js'
import NotificationsQuery from './notifications-query.js'
class Adapters {
constructor () {
@@ -57,6 +58,14 @@ class Adapters {
namesDb: level.namesDb,
profilesDb: level.profilesDb
})
this.notificationsQuery = new NotificationsQuery({
postsDb: level.postsDb,
postParentsDb: level.postParentsDb,
postChildrenDb: level.postChildrenDb,
likesDb: level.likesDb,
postLikesDb: level.postLikesDb,
followsDb: level.followsDb
})
return true
}
@@ -0,0 +1,166 @@
/*
Adapter for aggregating the viewer's notifications.
Notifications are read-only: the DB collects replies to the viewer's posts,
likes on the viewer's posts, and new follows of the viewer, then returns them
sorted newest-first with limit/offset pagination.
*/
import BCHJS from '@psf/bch-js'
class NotificationsQuery {
constructor (localConfig = {}) {
const {
postsDb,
postParentsDb,
postChildrenDb,
likesDb,
postLikesDb,
followsDb,
bchjs = new BCHJS({ restURL: process.env.RESTURL || 'https://api.fullstack.cash/v5/' })
} = localConfig
if (!postsDb) {
throw new Error('postsDb required when instantiating NotificationsQuery adapter.')
}
if (!postParentsDb) {
throw new Error('postParentsDb required when instantiating NotificationsQuery adapter.')
}
if (!postChildrenDb) {
throw new Error('postChildrenDb required when instantiating NotificationsQuery adapter.')
}
if (!likesDb) {
throw new Error('likesDb required when instantiating NotificationsQuery adapter.')
}
if (!postLikesDb) {
throw new Error('postLikesDb required when instantiating NotificationsQuery adapter.')
}
if (!followsDb) {
throw new Error('followsDb required when instantiating NotificationsQuery adapter.')
}
this.postsDb = postsDb
this.postParentsDb = postParentsDb
this.postChildrenDb = postChildrenDb
this.likesDb = likesDb
this.postLikesDb = postLikesDb
this.followsDb = followsDb
this.bchjs = bchjs
this.listNotifications = this.listNotifications.bind(this)
this._getPostOrNull = this._getPostOrNull.bind(this)
this._collectFollowNotifications = this._collectFollowNotifications.bind(this)
this._collectLikeNotifications = this._collectLikeNotifications.bind(this)
this._collectReplyNotifications = this._collectReplyNotifications.bind(this)
this._sortNotifications = this._sortNotifications.bind(this)
}
async _getPostOrNull (txid) {
try {
return await this.postsDb.get(txid)
} catch (err) {
if (err.notFound || err.code === 'LEVEL_NOT_FOUND') return null
throw err
}
}
// Collect active follows where this address is the followee.
async _collectFollowNotifications (addr) {
const myHash160 = this.bchjs.Address.toHash160(addr)
const notifications = []
for await (const [key, record] of this.followsDb.iterator()) {
if (record.unfollow === true) continue
if (record.followeePkHash !== myHash160) continue
const followerAddr = record.followerAddr || key.split(':')[0]
if (followerAddr === addr) continue
notifications.push({
type: 'follow',
txid: record.txid,
addr: followerAddr,
blockHeight: record.blockHeight ?? 0,
seen: record.seen ?? 0
})
}
return notifications
}
// Collect likes on posts authored by this address, excluding self-likes.
async _collectLikeNotifications (addr) {
const notifications = []
for await (const [likeTxid, like] of this.likesDb.iterator()) {
if (!like || like.addr === addr) continue
const post = await this._getPostOrNull(like.postTxid)
if (!post || post.addr !== addr) continue
notifications.push({
type: 'like',
txid: likeTxid,
addr: like.addr,
postTxid: like.postTxid,
blockHeight: like.blockHeight ?? 0,
seen: like.seen ?? 0
})
}
return notifications
}
// Collect replies to posts authored by this address, excluding own replies.
async _collectReplyNotifications (addr) {
const notifications = []
for await (const [, child] of this.postChildrenDb.iterator()) {
const parentTxid = child?.parentTxid
const childTxid = child?.childTxid
if (!parentTxid || !childTxid) continue
const parent = await this._getPostOrNull(parentTxid)
if (!parent || parent.addr !== addr) continue
const childPost = await this._getPostOrNull(childTxid)
if (!childPost || childPost.addr === addr) continue
notifications.push({
type: 'reply',
txid: childTxid,
addr: childPost.addr,
postTxid: parentTxid,
text: childPost.text,
blockHeight: child.blockHeight ?? childPost.blockHeight ?? 0,
seen: childPost.seen ?? 0
})
}
return notifications
}
_sortNotifications (notifications) {
return notifications.sort((a, b) => {
if (b.blockHeight !== a.blockHeight) return b.blockHeight - a.blockHeight
return (b.seen ?? 0) - (a.seen ?? 0)
})
}
// Return paginated notifications for addr, sorted newest-first.
async listNotifications (addr, { limit, offset } = {}) {
const [follows, likes, replies] = await Promise.all([
this._collectFollowNotifications(addr),
this._collectLikeNotifications(addr),
this._collectReplyNotifications(addr)
])
const all = this._sortNotifications(follows.concat(likes).concat(replies))
const total = all.length
const page = all.slice(offset, offset + limit)
return { notifications: page, total }
}
}
export default NotificationsQuery
@@ -18,6 +18,7 @@ class PostsRESTControllerLib {
this.getRecentPosts = this.getRecentPosts.bind(this)
this.getPostsByAddr = this.getPostsByAddr.bind(this)
this.getFollowingFeed = this.getFollowingFeed.bind(this)
this.getNotifications = this.getNotifications.bind(this)
this.getPostThread = this.getPostThread.bind(this)
this.runUseCase = this.runUseCase.bind(this)
this.listPostsForAddr = this.listPostsForAddr.bind(this)
@@ -145,6 +146,34 @@ class PostsRESTControllerLib {
await this.listPostsForAddr(ctx, this.useCases.listFollowingFeed)
}
/**
* @api {get} /posts/notifications/:addr List notifications for an address
* @apiPermission public
* @apiName GetNotifications
* @apiGroup REST Posts
*
* @apiDescription Returns replies to the viewer's posts, likes on the viewer's posts, and new follows of the viewer, sorted by block height (newest first).
*
* @apiParam {String} addr Viewer cash address
* @apiQuery {Number} [limit=100] Page size (max 100)
* @apiQuery {Number} [offset=0] Number of notifications to skip after sorting
*
* @apiExample Example usage:
* curl -X GET "localhost:5021/posts/notifications/bitcoincash:q...?limit=50&offset=0"
*
* @apiSuccess {Object[]} notifications Array of notification objects
* @apiSuccess {String} notifications.type One of reply, like, follow
* @apiSuccess {String} notifications.txid Action transaction id
* @apiSuccess {String} notifications.addr Actor cash address
* @apiSuccess {String} [notifications.postTxid] Liked/replied post txid
* @apiSuccess {String} [notifications.text] Reply text
* @apiSuccess {Number} notifications.blockHeight Block height when indexed
* @apiSuccess {Object} pagination Pagination metadata
*/
async getNotifications (ctx) {
await this.listPostsForAddr(ctx, this.useCases.listNotifications)
}
async getPostThread (ctx) {
const { txid } = ctx.params
await this.runUseCase(ctx, () => this.useCases.getPostThread.execute({
@@ -27,6 +27,7 @@ class PostsRouter {
this.router.get('/recent', this.postsRESTController.getRecentPosts)
this.router.get('/by/:addr', this.postsRESTController.getPostsByAddr)
this.router.get('/following/:addr', this.postsRESTController.getFollowingFeed)
this.router.get('/notifications/:addr', this.postsRESTController.getNotifications)
this.router.get('/:txid/thread', this.postsRESTController.getPostThread)
app.use(this.router.routes())
app.use(this.router.allowedMethods())
+6
View File
@@ -20,6 +20,7 @@ import GetPoll from './get-poll.js'
import GetPollOptions from './get-poll-options.js'
import GetPollVotes from './get-poll-votes.js'
import SearchAll from './search-all.js'
import ListNotifications from './list-notifications.js'
class UseCases {
constructor (localConfig = {}) {
@@ -49,6 +50,7 @@ class UseCases {
this.getPollOptions = null
this.getPollVotes = null
this.searchAll = null
this.listNotifications = null
}
async start () {
@@ -124,6 +126,10 @@ class UseCases {
adapters: this.adapters
})
this.listNotifications = new ListNotifications({
adapters: this.adapters
})
console.log('Use cases initialized.')
return true
@@ -0,0 +1,35 @@
/*
Use case: list notifications for a viewer, newest first.
Aggregates replies to the viewer's posts, likes on the viewer's posts, and
follows of the viewer, then paginates the combined result.
*/
import { parseLimit, parseOffset, parseRequiredString } from './lib/pagination.js'
import { ListUseCase } from './lib/use-case.js'
class ListNotifications extends ListUseCase {
constructor (localConfig = {}) {
super(localConfig, { useCaseName: 'ListNotifications', adapterName: 'notificationsQuery' })
}
async execute (inObj = {}) {
const addr = parseRequiredString(inObj.addr, 'addr')
const limit = parseLimit(inObj.limit)
const offset = parseOffset(inObj.offset)
const { notifications, total } = await this.adapters.notificationsQuery.listNotifications(addr, { limit, offset })
return {
notifications,
pagination: {
limit,
offset,
total,
hasMore: offset + notifications.length < total
}
}
}
}
export default ListNotifications
@@ -0,0 +1,251 @@
import { assert } from 'chai'
import sinon from 'sinon'
import NotificationsQuery from '../../../src/adapters/notifications-query.js'
describe('#NotificationsQuery', () => {
let sandbox
let postsDb
let postChildrenDb
let likesDb
let followsDb
let bchjs
let uut
const MY_ADDR = 'bitcoincash:qqlrzp23w08434twtmvr4fxw672whkjy0py26r63g3d'
const THEIR_ADDR = 'bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy'
const MY_HASH160 = 'myhash160'
const THEIR_HASH160 = 'theirhash160'
function makeIterator (items) {
return (async function * () {
for (const item of items) yield item
}())
}
beforeEach(() => {
sandbox = sinon.createSandbox()
postsDb = { get: sandbox.stub() }
postChildrenDb = { iterator: sandbox.stub() }
likesDb = { iterator: sandbox.stub() }
followsDb = { iterator: sandbox.stub() }
bchjs = {
Address: {
toHash160: sandbox.stub()
}
}
bchjs.Address.toHash160.withArgs(MY_ADDR).returns(MY_HASH160)
bchjs.Address.toHash160.withArgs(THEIR_ADDR).returns(THEIR_HASH160)
uut = new NotificationsQuery({
postsDb,
postParentsDb: {},
postChildrenDb,
likesDb,
postLikesDb: {},
followsDb,
bchjs
})
})
afterEach(() => sandbox.restore())
it('should throw when postsDb is missing', () => {
try {
// eslint-disable-next-line no-new
new NotificationsQuery({ postChildrenDb, likesDb, followsDb, bchjs })
assert.fail('Expected error')
} catch (err) {
assert.include(err.message, 'postsDb required')
}
})
it('should throw when postChildrenDb is missing', () => {
try {
// eslint-disable-next-line no-new
new NotificationsQuery({ postsDb, postParentsDb: {}, likesDb, postLikesDb: {}, followsDb, bchjs })
assert.fail('Expected error')
} catch (err) {
assert.include(err.message, 'postChildrenDb required')
}
})
it('should throw when likesDb is missing', () => {
try {
// eslint-disable-next-line no-new
new NotificationsQuery({ postsDb, postParentsDb: {}, postChildrenDb, postLikesDb: {}, followsDb, bchjs })
assert.fail('Expected error')
} catch (err) {
assert.include(err.message, 'likesDb required')
}
})
it('should throw when followsDb is missing', () => {
try {
// eslint-disable-next-line no-new
new NotificationsQuery({ postsDb, postParentsDb: {}, postChildrenDb, likesDb, postLikesDb: {}, bchjs })
assert.fail('Expected error')
} catch (err) {
assert.include(err.message, 'followsDb required')
}
})
it('should include a reply to my post', async () => {
const myPostTxid = 'a'.repeat(64)
const replyTxid = 'b'.repeat(64)
postChildrenDb.iterator.returns(makeIterator([
[`${myPostTxid}:${replyTxid}`, { parentTxid: myPostTxid, childTxid: replyTxid, blockHeight: 200 }]
]))
likesDb.iterator.returns(makeIterator([]))
followsDb.iterator.returns(makeIterator([]))
postsDb.get.withArgs(myPostTxid).resolves({ addr: MY_ADDR, text: 'hello' })
postsDb.get.withArgs(replyTxid).resolves({ addr: THEIR_ADDR, text: 'nice post', blockHeight: 200 })
const result = await uut.listNotifications(MY_ADDR, { limit: 100, offset: 0 })
assert.equal(result.total, 1)
assert.equal(result.notifications.length, 1)
const n = result.notifications[0]
assert.equal(n.type, 'reply')
assert.equal(n.txid, replyTxid)
assert.equal(n.addr, THEIR_ADDR)
assert.equal(n.postTxid, myPostTxid)
assert.equal(n.text, 'nice post')
})
it('should include a like on my post', async () => {
const myPostTxid = 'a'.repeat(64)
const likeTxid = 'b'.repeat(64)
postChildrenDb.iterator.returns(makeIterator([]))
followsDb.iterator.returns(makeIterator([]))
likesDb.iterator.returns(makeIterator([
[likeTxid, { addr: THEIR_ADDR, postTxid: myPostTxid, blockHeight: 300 }]
]))
postsDb.get.withArgs(myPostTxid).resolves({ addr: MY_ADDR, text: 'hello' })
const result = await uut.listNotifications(MY_ADDR, { limit: 100, offset: 0 })
assert.equal(result.total, 1)
const n = result.notifications[0]
assert.equal(n.type, 'like')
assert.equal(n.txid, likeTxid)
assert.equal(n.addr, THEIR_ADDR)
assert.equal(n.postTxid, myPostTxid)
})
it('should include a follow of me', async () => {
postChildrenDb.iterator.returns(makeIterator([]))
likesDb.iterator.returns(makeIterator([]))
followsDb.iterator.returns(makeIterator([
[`${THEIR_ADDR}:${MY_HASH160}`, { followerAddr: THEIR_ADDR, followeePkHash: MY_HASH160, unfollow: false, txid: 'c'.repeat(64), blockHeight: 150 }]
]))
const result = await uut.listNotifications(MY_ADDR, { limit: 100, offset: 0 })
assert.equal(result.total, 1)
const n = result.notifications[0]
assert.equal(n.type, 'follow')
assert.equal(n.txid, 'c'.repeat(64))
assert.equal(n.addr, THEIR_ADDR)
})
it('should exclude my own replies', async () => {
const myPostTxid = 'a'.repeat(64)
const replyTxid = 'b'.repeat(64)
postChildrenDb.iterator.returns(makeIterator([
[`${myPostTxid}:${replyTxid}`, { parentTxid: myPostTxid, childTxid: replyTxid, blockHeight: 100 }]
]))
likesDb.iterator.returns(makeIterator([]))
followsDb.iterator.returns(makeIterator([]))
postsDb.get.withArgs(myPostTxid).resolves({ addr: MY_ADDR, text: 'hello' })
postsDb.get.withArgs(replyTxid).resolves({ addr: MY_ADDR, text: 'my own reply' })
const result = await uut.listNotifications(MY_ADDR, { limit: 100, offset: 0 })
assert.equal(result.total, 0)
})
it('should exclude replies to posts by other people', async () => {
const theirPostTxid = 'a'.repeat(64)
const replyTxid = 'b'.repeat(64)
postChildrenDb.iterator.returns(makeIterator([
[`${theirPostTxid}:${replyTxid}`, { parentTxid: theirPostTxid, childTxid: replyTxid, blockHeight: 100 }]
]))
likesDb.iterator.returns(makeIterator([]))
followsDb.iterator.returns(makeIterator([]))
postsDb.get.withArgs(theirPostTxid).resolves({ addr: THEIR_ADDR, text: 'alice post' })
postsDb.get.withArgs(replyTxid).resolves({ addr: 'bitcoincash:other', text: 'reply' })
const result = await uut.listNotifications(MY_ADDR, { limit: 100, offset: 0 })
assert.equal(result.total, 0)
})
it('should exclude unfollows', async () => {
postChildrenDb.iterator.returns(makeIterator([]))
likesDb.iterator.returns(makeIterator([]))
followsDb.iterator.returns(makeIterator([
[`${THEIR_ADDR}:${MY_HASH160}`, { followerAddr: THEIR_ADDR, followeePkHash: MY_HASH160, unfollow: true, txid: 'c'.repeat(64), blockHeight: 150 }]
]))
const result = await uut.listNotifications(MY_ADDR, { limit: 100, offset: 0 })
assert.equal(result.total, 0)
})
it('should sort notifications by block height descending', async () => {
const myPostTxid = 'a'.repeat(64)
const replyTxid = 'b'.repeat(64)
const likeTxid = 'c'.repeat(64)
postChildrenDb.iterator.returns(makeIterator([
[`${myPostTxid}:${replyTxid}`, { parentTxid: myPostTxid, childTxid: replyTxid, blockHeight: 100 }]
]))
likesDb.iterator.returns(makeIterator([
[likeTxid, { addr: THEIR_ADDR, postTxid: myPostTxid, blockHeight: 300 }]
]))
followsDb.iterator.returns(makeIterator([]))
postsDb.get.withArgs(myPostTxid).resolves({ addr: MY_ADDR, text: 'hello' })
postsDb.get.withArgs(replyTxid).resolves({ addr: THEIR_ADDR, text: 'reply' })
const result = await uut.listNotifications(MY_ADDR, { limit: 100, offset: 0 })
assert.equal(result.total, 2)
assert.equal(result.notifications[0].type, 'like')
assert.equal(result.notifications[1].type, 'reply')
})
it('should paginate notifications', async () => {
const myPostTxid = 'a'.repeat(64)
const replyTxid = 'b'.repeat(64)
const likeTxid = 'c'.repeat(64)
postChildrenDb.iterator.returns(makeIterator([
[`${myPostTxid}:${replyTxid}`, { parentTxid: myPostTxid, childTxid: replyTxid, blockHeight: 100 }]
]))
likesDb.iterator.returns(makeIterator([
[likeTxid, { addr: THEIR_ADDR, postTxid: myPostTxid, blockHeight: 300 }]
]))
followsDb.iterator.returns(makeIterator([]))
postsDb.get.withArgs(myPostTxid).resolves({ addr: MY_ADDR, text: 'hello' })
postsDb.get.withArgs(replyTxid).resolves({ addr: THEIR_ADDR, text: 'reply' })
const result = await uut.listNotifications(MY_ADDR, { limit: 1, offset: 0 })
assert.equal(result.total, 2)
assert.equal(result.notifications.length, 1)
assert.equal(result.notifications[0].type, 'like')
})
})
@@ -28,6 +28,12 @@ describe('#PostsRESTController', () => {
posts: [{ txid: 'tx3', addr: 'addr-b', blockHeight: 600200 }],
pagination: { limit: 100, offset: 0, total: 1, hasMore: false }
})
},
listNotifications: {
execute: sandbox.stub().resolves({
notifications: [{ type: 'like', txid: 'tx5', addr: 'addr-c', blockHeight: 600300 }],
pagination: { limit: 100, offset: 0, total: 1, hasMore: false }
})
}
}
})
@@ -120,6 +126,25 @@ describe('#PostsRESTController', () => {
assert.include(ctx.throw.firstCall.args[1], 'boom')
})
it('should return notifications from use case', async () => {
const ctx = {
params: { addr: 'addr-c' },
query: { limit: '25', offset: '0' },
body: null,
throw: sandbox.stub()
}
await uut.getNotifications(ctx)
assert.equal(uut.useCases.listNotifications.execute.callCount, 1)
assert.deepEqual(uut.useCases.listNotifications.execute.firstCall.args[0], {
addr: 'addr-c',
limit: '25',
offset: '0'
})
assert.equal(ctx.body.notifications.length, 1)
assert.equal(ctx.body.notifications[0].type, 'like')
})
it('should return a post thread from use case', async () => {
const ctx = {
params: { txid: 'tx4' },
@@ -21,6 +21,7 @@ describe('#UseCases', () => {
},
topicQuery: {},
searchQuery: {},
notificationsQuery: {},
pollQuery: {}
}
}
@@ -55,6 +56,7 @@ describe('#UseCases', () => {
assert.isNotNull(uut.getPollOptions)
assert.isNotNull(uut.getPollVotes)
assert.isNotNull(uut.searchAll)
assert.isNotNull(uut.listNotifications)
})
})
})
@@ -0,0 +1,91 @@
import { assert } from 'chai'
import sinon from 'sinon'
import ListNotifications from '../../../src/use-cases/list-notifications.js'
describe('#ListNotifications', () => {
let uut
let sandbox
let notificationsQuery
beforeEach(() => {
sandbox = sinon.createSandbox()
notificationsQuery = {
listNotifications: sandbox.stub().resolves({
notifications: [{ type: 'follow', txid: 'tx1', addr: 'addr-a' }],
total: 1
})
}
uut = new ListNotifications({ adapters: { notificationsQuery } })
})
afterEach(() => sandbox.restore())
it('should throw when adapters are missing', () => {
try {
// eslint-disable-next-line no-new
new ListNotifications({})
assert.fail('Expected error')
} catch (err) {
assert.include(err.message, 'Adapters required')
}
})
it('should throw when notificationsQuery adapter is missing', () => {
try {
// eslint-disable-next-line no-new
new ListNotifications({ adapters: {} })
assert.fail('Expected error')
} catch (err) {
assert.include(err.message, 'notificationsQuery adapter required')
}
})
it('should reject a missing addr', async () => {
try {
await uut.execute({})
assert.fail('Expected error')
} catch (err) {
assert.equal(err.status, 400)
assert.include(err.message, 'addr is required')
}
})
it('should pass addr, limit, and offset to the query adapter', async () => {
await uut.execute({ addr: 'addr-a', limit: '10', offset: '5' })
assert.equal(notificationsQuery.listNotifications.callCount, 1)
assert.deepEqual(notificationsQuery.listNotifications.firstCall.args[0], 'addr-a')
assert.deepEqual(notificationsQuery.listNotifications.firstCall.args[1], { limit: 10, offset: 5 })
})
it('should default limit and offset', async () => {
await uut.execute({ addr: 'addr-a' })
assert.deepEqual(notificationsQuery.listNotifications.firstCall.args[1], { limit: 100, offset: 0 })
})
it('should reject limit over 100', async () => {
try {
await uut.execute({ addr: 'addr-a', limit: '101' })
assert.fail('Expected error')
} catch (err) {
assert.equal(err.status, 400)
assert.include(err.message, 'limit cannot exceed')
}
})
it('should attach pagination metadata', async () => {
notificationsQuery.listNotifications.resolves({
notifications: [{ type: 'like', txid: 'tx1', addr: 'addr-a' }],
total: 2
})
const result = await uut.execute({ addr: 'addr-a', limit: '1', offset: '0' })
assert.equal(result.notifications.length, 1)
assert.equal(result.pagination.limit, 1)
assert.equal(result.pagination.offset, 0)
assert.equal(result.pagination.total, 2)
assert.equal(result.pagination.hasMore, true)
})
})
+2 -2
View File
@@ -368,5 +368,5 @@ At the end of each session, update this file:
- Note the current `master` HEAD commit.
- State the next feature to work on.
Current `master` HEAD: `6d94372` (task `following-feed` — P6.6 Following Feed — spec'd and handed off to the coder. Search `20328ea`/`96956f3` was already verified.).
Next action: **await the coder → refactorer → architect pipeline for task `following-feed`**, then merge the architect branch into `master` and verify the affected components (client build/test/lint, DB test/lint). Send money (P5.1) was skipped by user decision (no clear use case). Future candidates after following-feed lands: P5.25.5 (MIP-0009 token exchange), P6.1 Repost, P6.2 Ranked feed, P6.3 Notifications, P6.5 Tags/hashtags.
Current `master` HEAD: `4422372` (merge of the architect's second-pass hardening of the Following feed — P6.6 — verified: client build/test/lint and DB test/lint all pass).
Next action: **P6.3 Notifications** — the Gherkin spec is drafted at `psf-memo-client/specs/notifications.feature` (8 scenarios, parsed clean, DRY-checked) and is awaiting user approval before handoff to the coder. After approval, commit it (`By specifier.`), invent the stable task name `notifications`, and send the `git_handoff` to the coder. Send money (P5.1) was skipped by user decision (no clear use case). Remaining candidates after notifications: P5.25.5 (MIP-0009 token exchange), P6.1 Repost, P6.2 Ranked feed, P6.5 Tags/hashtags.
+4 -4
View File
@@ -207,15 +207,15 @@ Polls require a new data model and rendering. The indexer has no handler yet.
| 6.3 | Notifications | C, D | replies / likes / follows to my posts |
| 6.4 | Search | C, D | posts / profiles / topics — ✅ shipped (task `search`) |
| 6.5 | Tags / hashtags | C, D | link + filter by tag |
| 6.6 | Following feed | C, D | feed filtered to followed users — 🔜 in pipeline (task `following-feed`) |
| 6.6 | Following feed | C, D | feed filtered to followed users — ✅ shipped (task `following-feed`) |
---
## Suggested next spec
**Following feed (P6.6)**🔜 spec'd and handed off to the coder (task `following-feed`, commit `6d94372`). Following Feed is a read-only aggregation: the viewer sees top-level posts (replies excluded) authored only by profiles they follow, newest first, never their own posts.
- `psf-memo-db`: new `GET /posts/following/:addr?limit=&offset=` returning `{ posts, pagination }`, joining the follows index (`FollowQuery.listFollowing`) with the posts index. Empty following or no posts → empty result set.
- `psf-memo-client`: a "Following" nav page at `/posts/following` that reads the viewer's wallet address, calls the new endpoint (a `MemoDb.getFollowingFeed`), and renders posts like the recent feed; shows "you aren't following anyone" when following nobody.
**Following feed (P6.6)** shipped (task `following-feed`), merged from the pipeline and verified (client build/test/lint, DB test/lint). A second-pass hardening merge from the architect (2026-09-02) extracted `parseRequiredString` into `pagination.js` (shared by the addr/room-scoped list use cases) and added client + DB composition-root hardening tests; merged to `master` at `4422372` and re-verified. Following Feed is a read-only aggregation: the viewer sees top-level posts (replies excluded) authored only by profiles they follow, newest first, never their own posts.
- `psf-memo-db`: `GET /posts/following/:addr?limit=&offset=` returning `{ posts, pagination }`, joining the follows index (`FollowQuery.listFollowing`) with the posts index (`PostQuery.scanFollowingFeedTxidsAndCount`). Empty following or no posts → empty result set.
- `psf-memo-client`: a "Following" nav page at `/posts/following` that reads the viewer's wallet address, calls `MemoDb.getFollowingFeed`, and renders posts like the recent feed; shows "You are not following anyone." when following nobody and "No posts from profiles you follow." when followed profiles have no posts.
- Spec: `psf-memo-client/specs/following-feed.feature`.
**Search (P6.4)** — ✅ shipped (task `search`), merged from the pipeline and verified (client build/test/lint, DB test/lint):
+1
View File
@@ -17,6 +17,7 @@ You are the architect.
- Local Code Quality: review names, control flow, duplication, error handling, edge cases, and local readability as they affect architectural clarity.
## Startup Tools
- At startup, read `docs/architect-process-notes.md` (architect-only working notes on process exceptions and tooling behavior) and keep it current as you discover new exceptions.
- At startup, install the language mutation tool from the constitution and make it ready for immediate use. Use it to cover the uncovered, and kill survivors.
- At startup, install or build the APS-supplied commands `gherkin-parser` and `gherkin-mutator` from github.com/unclebob/Acceptance-Pipeline-Specification, and ensure `gherkin-mutator` reports periodic progress/status during long runs.
- Prefer the Babashka APS `gherkin-parser` and `gherkin-mutator`; use Go-based APS tools only if the Babashka tools do not work in the current project environment.