diff --git a/psf-memo-client/acceptance/lib/handlers.js b/psf-memo-client/acceptance/lib/handlers.js
index c9ab489..7b98301 100644
--- a/psf-memo-client/acceptance/lib/handlers.js
+++ b/psf-memo-client/acceptance/lib/handlers.js
@@ -39,6 +39,7 @@ const ProfilePage = require('../../src/services/profile-page')
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 MemoTopicFollow = require('../../src/services/memo-topic-follow')
const MemoTopicPost = require('../../src/services/memo-topic-post')
const TopicPostPage = require('../../src/services/topic-post-page')
@@ -178,9 +179,12 @@ function makeThread () {
}
// A fake psf-memo-db API backing the read-only feed, profile, thread,
-// and topic pages used to verify read-side behavior.
+// topic, and search pages used to verify read-side behavior.
function makeMemoDb () {
const posts = []
+ const profiles = []
+ const searchPosts = []
+ const searchProfiles = []
const threads = {}
const followState = {}
const muteState = {}
@@ -191,6 +195,9 @@ function makeMemoDb () {
return {
posts,
+ profiles,
+ searchPosts,
+ searchProfiles,
threads,
followState,
topics,
@@ -199,6 +206,12 @@ function makeMemoDb () {
addPost (post) {
posts.push(post)
},
+ addSearchPost (post) {
+ searchPosts.push(post)
+ },
+ addSearchProfile (profile) {
+ searchProfiles.push(profile)
+ },
addTopic (room, postCount) {
topicCounts.set(room, postCount)
topicPosts[room] = []
@@ -240,6 +253,25 @@ function makeMemoDb () {
}
return addrs
},
+ async search (q) {
+ const normalized = String(q).trim().toLowerCase()
+ if (normalized.length === 0) {
+ return { posts: [], profiles: [], pagination: { total: 0, hasMore: false } }
+ }
+ const matchedPosts = searchPosts.filter((p) =>
+ typeof p.text === 'string' && p.text.toLowerCase().includes(normalized)
+ )
+ const matchedProfiles = searchProfiles.filter((p) =>
+ (typeof p.name === 'string' && p.name.toLowerCase().includes(normalized)) ||
+ (typeof p.text === 'string' && p.text.toLowerCase().includes(normalized))
+ )
+ const total = matchedPosts.length + matchedProfiles.length
+ return {
+ posts: matchedPosts,
+ profiles: matchedProfiles,
+ pagination: { total, hasMore: false }
+ }
+ },
async getRecentPosts ({ limit = 100, offset = 0 } = {}) {
const page = posts.slice(offset, offset + limit)
return { posts: page, pagination: { total: posts.length, limit, offset, hasMore: offset + page.length < posts.length } }
@@ -316,6 +348,10 @@ function createWorld () {
memoDb,
navigate: (path) => { world.currentPath = path }
})
+ world.searchPage = new SearchPage({
+ memoDb,
+ navigate: (path) => { world.currentPath = path }
+ })
// The New Post Page controller wraps the memo post behavior. Its navigate
// adapter updates the world's current path so navigation can be asserted.
@@ -2088,6 +2124,86 @@ const handlers = [
throw new Error(`Expected ${expectedCode}, got ${world.pollVotePage.submitError}.`)
}
}
+ },
+ {
+ name: 'API has search post',
+ pattern: /^the psf-memo-db API has a post with the text "(.+)"$/,
+ run (m, example, world) {
+ const text = resolveParam(m[1], example)
+ const txid = require('crypto').createHash('sha256').update(text).digest('hex')
+ world.memoDb.addSearchPost({
+ txid,
+ addr: `addr-${txid.slice(0, 8)}`,
+ text,
+ blockHeight: 100
+ })
+ }
+ },
+ {
+ name: 'API has search profile',
+ pattern: /^the psf-memo-db API has a profile named "(.+)" with the bio "(.+)"$/,
+ run (m, example, world) {
+ const name = resolveParam(m[1], example)
+ const text = resolveParam(m[2], example)
+ const addr = `addr-${require('crypto').createHash('sha256').update(name).digest('hex').slice(0, 8)}`
+ world.memoDb.addSearchProfile({ addr, name, text, blockHeight: 100 })
+ }
+ },
+ {
+ name: 'open search page',
+ pattern: /^I open the Search page$/,
+ async run (m, example, world) {
+ world.currentPath = SearchPage.SEARCH_PATH
+ }
+ },
+ {
+ name: 'submit search',
+ pattern: /^I submit a search for (.+)$/,
+ async run (m, example, world) {
+ const query = resolveParam(m[1], example)
+ world.searchPage.setQuery(query)
+ await world.searchPage.submit()
+ }
+ },
+ {
+ name: 'search results include post text',
+ pattern: /^the search results include a post with the text (.+)$/,
+ run (m, example, world) {
+ const expected = resolveParam(m[1], example)
+ const found = world.searchPage.posts.find((p) => p.text === expected)
+ if (!found) {
+ throw new Error(`Search results do not include a post with text "${expected}".`)
+ }
+ }
+ },
+ {
+ name: 'search results include profile name',
+ pattern: /^the search results include a profile named (.+)$/,
+ run (m, example, world) {
+ const expected = resolveParam(m[1], example)
+ const found = world.searchPage.profiles.find((p) => p.name === expected)
+ if (!found) {
+ throw new Error(`Search results do not include a profile named "${expected}".`)
+ }
+ }
+ },
+ {
+ name: 'search results include no posts',
+ pattern: /^the search results include no posts$/,
+ run (m, example, world) {
+ if (world.searchPage.posts.length !== 0) {
+ throw new Error(`Expected no posts in search results, got ${world.searchPage.posts.length}.`)
+ }
+ }
+ },
+ {
+ name: 'search results include no profiles',
+ pattern: /^the search results include no profiles$/,
+ run (m, example, world) {
+ if (world.searchPage.profiles.length !== 0) {
+ throw new Error(`Expected no profiles in search results, got ${world.searchPage.profiles.length}.`)
+ }
+ }
}
]
diff --git a/psf-memo-client/specs/search.feature b/psf-memo-client/specs/search.feature
index 8f50dea..345311a 100644
--- a/psf-memo-client/specs/search.feature
+++ b/psf-memo-client/specs/search.feature
@@ -1,3 +1,7 @@
+# acceptance-mutation-manifest-begin
+# {"version":1,"tested_at":"2026-08-29T15:42:33.013729780Z","feature_name":"Search","feature_path":"/home/trout/work/psf-memo/.worktrees/architect/psf-memo-client/specs/search.feature","background_hash":"9e7fd3c0f7630e1c8dbe0201df1c30b7b45c1e9a208ab42cf7ff005bdb7881a6","implementation_hash":"unknown","scenarios":[]}
+# acceptance-mutation-manifest-end
+
# Scenarios: Search - 1, Search - 2, Search - 3, Search - 4, Search - 5
#
# Search is a public read-only feature; it does not require a wallet and does
diff --git a/psf-memo-client/src/components/app-body/index.js b/psf-memo-client/src/components/app-body/index.js
index a017a41..884009c 100644
--- a/psf-memo-client/src/components/app-body/index.js
+++ b/psf-memo-client/src/components/app-body/index.js
@@ -33,6 +33,7 @@ import SetAvatarUrl from './set-avatar-url'
import Account from './account'
import Topics from './topics'
import TopicFeed from './topic-feed'
+import Search from './search'
function AppBody (props) {
// Dependency injection through props
@@ -52,6 +53,7 @@ function AppBody (props) {
} />
} />
} />
+ } />
} />
} />
} />
diff --git a/psf-memo-client/src/components/app-body/search/index.js b/psf-memo-client/src/components/app-body/search/index.js
new file mode 100644
index 0000000..c6a96b3
--- /dev/null
+++ b/psf-memo-client/src/components/app-body/search/index.js
@@ -0,0 +1,132 @@
+/*
+ Search page: submit a query and display matching posts and profiles.
+*/
+
+// Global npm libraries
+import React, { useState } from 'react'
+import { Container, Row, Col, Form, Button, Spinner, ListGroup } from 'react-bootstrap'
+import { Link } from 'react-router-dom'
+
+// Local libraries
+import MemoDb from '../../../services/memo-db'
+import SearchPage from '../../../services/search-page'
+import '../../../App.css'
+
+function SearchResults (props) {
+ const { posts, profiles, searched } = props
+
+ if (!searched) return null
+
+ if (posts.length === 0 && profiles.length === 0) {
+ return
No results found.
+ }
+
+ return (
+ <>
+ {posts.length > 0 && (
+ <>
+ Posts
+
+ {posts.map((post) => (
+
+ {post.text}
+
+ {post.addr}
+
+
+ ))}
+
+ >
+ )}
+
+ {profiles.length > 0 && (
+ <>
+ Profiles
+
+ {profiles.map((profile) => (
+
+
+ {profile.name || profile.addr}
+
+ {profile.text && {profile.text}
}
+
+ ))}
+
+ >
+ )}
+ >
+ )
+}
+
+function Search (props) {
+ const [query, setQuery] = useState('')
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState(null)
+ const [posts, setPosts] = useState([])
+ const [profiles, setProfiles] = useState([])
+ const [searched, setSearched] = useState(false)
+
+ const handleSubmit = async (event) => {
+ event.preventDefault()
+ setLoading(true)
+ setError(null)
+ setSearched(true)
+
+ try {
+ const memoDb = new MemoDb()
+ const page = new SearchPage({ memoDb })
+ page.setQuery(query)
+ const result = await page.submit()
+ setPosts(result.posts || [])
+ setProfiles(result.profiles || [])
+ } catch (err) {
+ setError(err.message || 'Search failed')
+ setPosts([])
+ setProfiles([])
+ }
+
+ setLoading(false)
+ }
+
+ return (
+
+
+
+
+
+
+ setQuery(e.target.value)}
+ disabled={loading}
+ />
+
+
+
+
+ {error && {error}
}
+
+ {loading && (
+
+
+ Loading...
+
+
+ )}
+
+ {!loading && }
+
+
+
+ )
+}
+
+export default Search
diff --git a/psf-memo-client/src/components/nav-menu/index.js b/psf-memo-client/src/components/nav-menu/index.js
index 95a511a..7baa082 100644
--- a/psf-memo-client/src/components/nav-menu/index.js
+++ b/psf-memo-client/src/components/nav-menu/index.js
@@ -78,6 +78,14 @@ function NavMenu (props) {
Topics
+
+ Search
+
+
{})
+ this.query = ''
+ this.posts = []
+ this.profiles = []
+ this.pagination = null
+ }
+
+ setQuery (q) {
+ this.query = typeof q === 'string' ? q.trim() : ''
+ return this
+ }
+
+ async submit ({ limit = 100, offset = 0 } = {}) {
+ if (!this.memoDb) {
+ throw new Error('Search page requires a memo db client.')
+ }
+
+ const data = await this.memoDb.search(this.query, { limit, offset })
+ this.posts = data.posts || []
+ this.profiles = data.profiles || []
+ this.pagination = data.pagination || null
+
+ return {
+ posts: this.posts,
+ profiles: this.profiles,
+ pagination: this.pagination
+ }
+ }
+
+ getPost (txid) {
+ return this.posts.find((post) => post.txid === txid) || null
+ }
+
+ getProfile (addr) {
+ return this.profiles.find((profile) => profile.addr === addr) || null
+ }
+}
+
+SearchPage.SEARCH_PATH = SEARCH_PATH
+
+module.exports = SearchPage
+
+// mutate4javascript-manifest-begin
+// {"version":1,"tested_at":"2026-08-29T15:29:31.910Z","module_hash":"5ff867161b6e0af82435936924bfc3f2843e94b60354bbb39833714adb2a7448","functions":[{"id":"func/SearchPage.constructor","name":"SearchPage.constructor","line":12,"end_line":19,"hash":"6e48e42aae340ec24012bd86529d4493664e896d0d86a0bcfaf75e813d196da7"},{"id":"func/SearchPage.setQuery","name":"SearchPage.setQuery","line":21,"end_line":24,"hash":"45cdbb5a6cc4329f2ae2918130de995482f5587efdfa8bf48191672c0917cab3"},{"id":"func/SearchPage.submit","name":"SearchPage.submit","line":26,"end_line":41,"hash":"2e1394b99ec30f3b0fb867ab45c07c439c0d71ecef868dd37bfd7af11c584a2a"},{"id":"func/SearchPage.getPost","name":"SearchPage.getPost","line":43,"end_line":45,"hash":"1a6ae1a02b0f79b5b62a2b2324a5f1edbd743bec004fe73cfef7970bead56885"},{"id":"func/SearchPage.getProfile","name":"SearchPage.getProfile","line":47,"end_line":49,"hash":"c93f06a279f8976938fc8b91ce24e9e742c700ac6ff271328192dba9140ae195"}]}
+// mutate4javascript-manifest-end
diff --git a/psf-memo-client/test/unit/search-page.test.js b/psf-memo-client/test/unit/search-page.test.js
new file mode 100644
index 0000000..4791d63
--- /dev/null
+++ b/psf-memo-client/test/unit/search-page.test.js
@@ -0,0 +1,130 @@
+/*
+ Unit tests for the Search page controller.
+
+ The search page is a thin, testable wrapper around the MemoDb client. It
+ captures a query, submits it to the search endpoint, and exposes the returned
+ posts and profiles so the view can render them.
+*/
+
+'use strict'
+
+const test = require('node:test')
+const assert = require('node:assert/strict')
+const SearchPage = require('../../src/services/search-page')
+
+function makeMemoDb (posts, profiles, pagination) {
+ return {
+ async search (q, { limit, offset }) {
+ return { posts, profiles, pagination }
+ }
+ }
+}
+
+test('load returns posts and profiles', async () => {
+ const posts = [{ txid: 'a'.repeat(64), text: 'hello world' }]
+ const profiles = [{ addr: 'addr1', name: 'Alice Trout' }]
+ const page = new SearchPage({ memoDb: makeMemoDb(posts, profiles, { total: 2 }) })
+
+ page.setQuery('hello')
+ const result = await page.submit()
+
+ assert.deepEqual(result.posts, posts)
+ assert.deepEqual(result.profiles, profiles)
+ assert.equal(result.pagination.total, 2)
+})
+
+test('submit forwards query, limit and offset to the memo db client', async () => {
+ const calls = []
+ const memoDb = {
+ async search (q, params) {
+ calls.push({ q, params })
+ return { posts: [], profiles: [], pagination: {} }
+ }
+ }
+ const page = new SearchPage({ memoDb })
+
+ page.setQuery('alice')
+ await page.submit({ limit: 10, offset: 20 })
+
+ assert.deepEqual(calls, [{ q: 'alice', params: { limit: 10, offset: 20 } }])
+})
+
+test('submit defaults limit to 100 and offset to 0', async () => {
+ const calls = []
+ const memoDb = {
+ async search (q, params) {
+ calls.push({ q, params })
+ return { posts: [], profiles: [], pagination: {} }
+ }
+ }
+ const page = new SearchPage({ memoDb })
+
+ page.setQuery('memo')
+ await page.submit()
+
+ assert.deepEqual(calls, [{ q: 'memo', params: { limit: 100, offset: 0 } }])
+})
+
+test('submit throws when no memo db client is provided', async () => {
+ const page = new SearchPage({})
+
+ await assert.rejects(
+ () => page.submit(),
+ /requires a memo db client/
+ )
+})
+
+test('setQuery stores the trimmed query', () => {
+ const page = new SearchPage({ memoDb: { async search () { return {} } } })
+
+ page.setQuery(' hello ')
+
+ assert.equal(page.query, 'hello')
+})
+
+test('constructor preserves an injected navigate handler', () => {
+ const navigate = () => 'x'
+ const page = new SearchPage({ memoDb: { async search () { return {} } }, navigate })
+
+ assert.equal(page.navigate, navigate)
+})
+
+test('getPost returns the matching post', async () => {
+ const posts = [{ txid: 'a'.repeat(64), text: 'hello world' }]
+ const page = new SearchPage({ memoDb: makeMemoDb(posts, [], {}) })
+
+ page.setQuery('hello')
+ await page.submit()
+
+ assert.equal(page.getPost('a'.repeat(64)).text, 'hello world')
+})
+
+test('getPost returns null when no post matches', async () => {
+ const posts = [{ txid: 'a'.repeat(64), text: 'hello world' }]
+ const page = new SearchPage({ memoDb: makeMemoDb(posts, [], {}) })
+
+ page.setQuery('hello')
+ await page.submit()
+
+ assert.equal(page.getPost('b'.repeat(64)), null)
+})
+
+test('getProfile returns the matching profile', async () => {
+ const profiles = [{ addr: 'addr1', name: 'Alice Trout' }]
+ const page = new SearchPage({ memoDb: makeMemoDb([], profiles, {}) })
+
+ page.setQuery('alice')
+ await page.submit()
+
+ assert.equal(page.getProfile('addr1').name, 'Alice Trout')
+})
+
+test('getProfile returns null when no profile matches', async () => {
+ const profiles = [{ addr: 'addr1', name: 'Alice Trout' }]
+ const page = new SearchPage({ memoDb: makeMemoDb([], profiles, {}) })
+
+ page.setQuery('alice')
+ await page.submit()
+
+ assert.equal(page.getProfile('addr2'), null)
+})
diff --git a/psf-memo-db/src/adapters/index.js b/psf-memo-db/src/adapters/index.js
index e17ab83..f18df0c 100644
--- a/psf-memo-db/src/adapters/index.js
+++ b/psf-memo-db/src/adapters/index.js
@@ -10,6 +10,7 @@ import FollowQuery from './follow-query.js'
import MuteQuery from './mute-query.js'
import TopicQuery from './topic-query.js'
import PollQuery from './poll-query.js'
+import SearchQuery from './search-query.js'
class Adapters {
constructor () {
@@ -50,6 +51,12 @@ class Adapters {
pollOptionsDb: level.pollOptionsDb,
pollVotesDb: level.pollVotesDb
})
+ this.searchQuery = new SearchQuery({
+ postsDb: level.postsDb,
+ postParentsDb: level.postParentsDb,
+ namesDb: level.namesDb,
+ profilesDb: level.profilesDb
+ })
return true
}
diff --git a/psf-memo-db/src/adapters/lib/load-reply-txids.js b/psf-memo-db/src/adapters/lib/load-reply-txids.js
new file mode 100644
index 0000000..ebc1565
--- /dev/null
+++ b/psf-memo-db/src/adapters/lib/load-reply-txids.js
@@ -0,0 +1,21 @@
+/*
+ Shared helper to collect the set of child txids that are replies.
+
+ Both the post query and search query adapters need to know which posts are
+ replies so they can exclude them from top-level listings. Centralizing the
+ scan keeps reply-detection behavior identical across adapters.
+*/
+
+export async function loadReplyTxids (postParentsDb) {
+ const replyTxids = new Set()
+
+ for await (const [childTxid] of postParentsDb.iterator()) {
+ replyTxids.add(childTxid)
+ }
+
+ return replyTxids
+}
+
+// mutate4javascript-manifest-begin
+// {"version":1,"tested_at":"2026-08-29T15:18:40.417Z","module_hash":"4024492a889a87fa3669b0daf955e2a906c75407b2b1f83008458710f1206c9e","functions":[{"id":"func/loadReplyTxids","name":"loadReplyTxids","line":9,"end_line":17,"hash":"17ac31205b8d5e508bda758939bcde282f79add47236c27de07402654ae0b2df"}]}
+// mutate4javascript-manifest-end
diff --git a/psf-memo-db/src/adapters/post-query.js b/psf-memo-db/src/adapters/post-query.js
index 23383a5..b718d68 100644
--- a/psf-memo-db/src/adapters/post-query.js
+++ b/psf-memo-db/src/adapters/post-query.js
@@ -6,6 +6,8 @@
- postLikes: likes grouped by liked post txid
*/
+import { loadReplyTxids } from './lib/load-reply-txids.js'
+
const HEIGHT_PAD = 12
class PostQuery {
@@ -88,13 +90,7 @@ class PostQuery {
}
async loadReplyTxids () {
- const replyTxids = new Set()
-
- for await (const [childTxid] of this.postParentsDb.iterator()) {
- replyTxids.add(childTxid)
- }
-
- return replyTxids
+ return loadReplyTxids(this.postParentsDb)
}
async isReply (txid) {
@@ -315,5 +311,5 @@ class PostQuery {
export default PostQuery
// mutate4javascript-manifest-begin
-// {"version":1,"tested_at":"2026-08-28T03:10:29.020Z","module_hash":"021ee6327c28eae9b940a6af05d940c1a47ec64fc1b2f1d1e24aea833945777d","functions":[{"id":"func/PostQuery.constructor","name":"PostQuery.constructor","line":12,"end_line":57,"hash":"d7104ee46a40c256bc93c795c04a71f9ff212d723480cbc75ec6d497a0995e84"},{"id":"func/PostQuery.padHeight","name":"PostQuery.padHeight","line":59,"end_line":61,"hash":"be6c442a4d3d86ab3b60314756b7f7c0592479c21cb3b2e1273dcf139a84fb00"},{"id":"func/PostQuery.postHeightKey","name":"PostQuery.postHeightKey","line":63,"end_line":65,"hash":"2d4dff9464aa4c1e805da5de2ba314fbd856530c046705237ea848d5feff8c7c"},{"id":"func/PostQuery.addrPostHeightKey","name":"PostQuery.addrPostHeightKey","line":67,"end_line":69,"hash":"48579f08593cee36cc2f107c2526ac9def687b354cc5e54089defa3fd37117eb"},{"id":"func/PostQuery.postLikeKey","name":"PostQuery.postLikeKey","line":71,"end_line":73,"hash":"5d16caac2cf88932702b28f9d95927183f8904948eb8c16c7e8c672cd3a0780c"},{"id":"func/PostQuery.txidFromPostHeight","name":"PostQuery.txidFromPostHeight","line":75,"end_line":78,"hash":"0fd135c51089dcc9bd2f166bb9f28faaa6a03d7eb84cf03cb9b36e97cdb3139c"},{"id":"func/PostQuery.txidFromAddrPostHeight","name":"PostQuery.txidFromAddrPostHeight","line":80,"end_line":82,"hash":"314d5432292a78b273e3165343d3e09276600cbaf239f76bcd1b36fe5fcf5e10"},{"id":"func/PostQuery.txidFromKeyParts","name":"PostQuery.txidFromKeyParts","line":85,"end_line":88,"hash":"59fb8173599070095d87e5d6f56eeb999f79e75e4d3f3e435515e9db8ac71868"},{"id":"func/PostQuery.loadReplyTxids","name":"PostQuery.loadReplyTxids","line":90,"end_line":98,"hash":"a397af5257d234a2aa9c18b1de49aa3738bf31645e0efbb9ed71bd0940abdb18"},{"id":"func/PostQuery.isReply","name":"PostQuery.isReply","line":100,"end_line":108,"hash":"be2e3729bd5f05cbfbab3630678eb5c389bd04c616d53b1e0c48c852f4cb25b1"},{"id":"func/PostQuery.countRepliesForTxids","name":"PostQuery.countRepliesForTxids","line":111,"end_line":125,"hash":"16268cd2a0f8db020a099b704c3d5a3cea5daf6a0936dd1f8d85c38809f006aa"},{"id":"func/PostQuery.buildReplyCountMap","name":"PostQuery.buildReplyCountMap","line":128,"end_line":138,"hash":"1d9762d70dca3439c0bb382b09882faee215f1d53f0656aff8bcbde429ec63b4"},{"id":"func/PostQuery.countLikesForTxids","name":"PostQuery.countLikesForTxids","line":141,"end_line":156,"hash":"54e6e3e6467e72d9dd033c9861b3b9ef5e426a76aed7a13e8ac1a0f0389468a3"},{"id":"func/PostQuery.likeTxidFromPostLike","name":"PostQuery.likeTxidFromPostLike","line":158,"end_line":162,"hash":"7e07a9c278a9abae8f646b4f1950f88760f2d5c6445600a42fa42f36d029a45a"},{"id":"func/PostQuery.buildLikeCountMap","name":"PostQuery.buildLikeCountMap","line":166,"end_line":178,"hash":"a82ca3b46d68426edbe25f176c4a6019ea5fb6abea4c5d5e84aff3b381f63c61"},{"id":"func/PostQuery.postTxidFromPostLike","name":"PostQuery.postTxidFromPostLike","line":180,"end_line":184,"hash":"da7f8d0c63dbc074fb25da1a31dde5075c2c3469b84a5c700bd4a2961879d8e9"},{"id":"func/PostQuery.getPostOrNull","name":"PostQuery.getPostOrNull","line":187,"end_line":194,"hash":"792ce2a8d5f19ed3d159c7af7e95c310e5b0c05cbef4de5be8f8f78403680b91"},{"id":"func/PostQuery.topLevelPostTxids","name":"PostQuery.topLevelPostTxids","line":198,"end_line":206,"hash":"0457d8b43b692d7bbfde283e63663d74542778d3893b45202fcb6ae0f1fc6776"},{"id":"func/PostQuery.scanRecentPostTxids","name":"PostQuery.scanRecentPostTxids","line":208,"end_line":223,"hash":"bed887c0eeec051e082657cac518bbd2f60df84bbb86c9d8aa76015194e7a73a"},{"id":"func/PostQuery.scanPostsByAddrTxidsAndCount","name":"PostQuery.scanPostsByAddrTxidsAndCount","line":229,"end_line":257,"hash":"a872d7a1f71ce1ba6a1dee46a820989cbbb7287dfc433051be1e15890dbc010b"},{"id":"func/PostQuery.scanPostsByAddrTxids","name":"PostQuery.scanPostsByAddrTxids","line":260,"end_line":263,"hash":"3498f2b9615e79b9472a5c7be26520c78db7c1661071c90a882f981d8acca477"},{"id":"func/PostQuery.loadPostsByTxids","name":"PostQuery.loadPostsByTxids","line":265,"end_line":281,"hash":"86b05107f3ecc240b2e734217cedf29ef44c48474bf31c1c8f75f2e4b4f84bea"},{"id":"func/PostQuery.countTopLevelPosts","name":"PostQuery.countTopLevelPosts","line":283,"end_line":294,"hash":"a26a6fdd12201de71965545f4113e3598f325fa4d96076289d371e4157c667ff"},{"id":"func/PostQuery.countTopLevelPostsByAddr","name":"PostQuery.countTopLevelPostsByAddr","line":297,"end_line":312,"hash":"6e12c0b66758cd0103ff6abad1379b3883bea82f084d2382991b4643dfb63360"}]}
+// {"version":1,"tested_at":"2026-08-29T14:30:59.518Z","module_hash":"48f80ca7bfd456c9cf4c373e5ed22db42826d44df7a720babbd4e6b8e80a8e76","functions":[{"id":"func/PostQuery.constructor","name":"PostQuery.constructor","line":14,"end_line":59,"hash":"d7104ee46a40c256bc93c795c04a71f9ff212d723480cbc75ec6d497a0995e84"},{"id":"func/PostQuery.padHeight","name":"PostQuery.padHeight","line":61,"end_line":63,"hash":"be6c442a4d3d86ab3b60314756b7f7c0592479c21cb3b2e1273dcf139a84fb00"},{"id":"func/PostQuery.postHeightKey","name":"PostQuery.postHeightKey","line":65,"end_line":67,"hash":"2d4dff9464aa4c1e805da5de2ba314fbd856530c046705237ea848d5feff8c7c"},{"id":"func/PostQuery.addrPostHeightKey","name":"PostQuery.addrPostHeightKey","line":69,"end_line":71,"hash":"48579f08593cee36cc2f107c2526ac9def687b354cc5e54089defa3fd37117eb"},{"id":"func/PostQuery.postLikeKey","name":"PostQuery.postLikeKey","line":73,"end_line":75,"hash":"5d16caac2cf88932702b28f9d95927183f8904948eb8c16c7e8c672cd3a0780c"},{"id":"func/PostQuery.txidFromPostHeight","name":"PostQuery.txidFromPostHeight","line":77,"end_line":80,"hash":"0fd135c51089dcc9bd2f166bb9f28faaa6a03d7eb84cf03cb9b36e97cdb3139c"},{"id":"func/PostQuery.txidFromAddrPostHeight","name":"PostQuery.txidFromAddrPostHeight","line":82,"end_line":84,"hash":"314d5432292a78b273e3165343d3e09276600cbaf239f76bcd1b36fe5fcf5e10"},{"id":"func/PostQuery.txidFromKeyParts","name":"PostQuery.txidFromKeyParts","line":87,"end_line":90,"hash":"59fb8173599070095d87e5d6f56eeb999f79e75e4d3f3e435515e9db8ac71868"},{"id":"func/PostQuery.loadReplyTxids","name":"PostQuery.loadReplyTxids","line":92,"end_line":94,"hash":"74621495a3affc6ef8688b9d6a814a91c99b22c86c348d261c952aad25df1661"},{"id":"func/PostQuery.isReply","name":"PostQuery.isReply","line":96,"end_line":104,"hash":"be2e3729bd5f05cbfbab3630678eb5c389bd04c616d53b1e0c48c852f4cb25b1"},{"id":"func/PostQuery.countRepliesForTxids","name":"PostQuery.countRepliesForTxids","line":107,"end_line":121,"hash":"16268cd2a0f8db020a099b704c3d5a3cea5daf6a0936dd1f8d85c38809f006aa"},{"id":"func/PostQuery.buildReplyCountMap","name":"PostQuery.buildReplyCountMap","line":124,"end_line":134,"hash":"1d9762d70dca3439c0bb382b09882faee215f1d53f0656aff8bcbde429ec63b4"},{"id":"func/PostQuery.countLikesForTxids","name":"PostQuery.countLikesForTxids","line":137,"end_line":152,"hash":"54e6e3e6467e72d9dd033c9861b3b9ef5e426a76aed7a13e8ac1a0f0389468a3"},{"id":"func/PostQuery.likeTxidFromPostLike","name":"PostQuery.likeTxidFromPostLike","line":154,"end_line":158,"hash":"7e07a9c278a9abae8f646b4f1950f88760f2d5c6445600a42fa42f36d029a45a"},{"id":"func/PostQuery.buildLikeCountMap","name":"PostQuery.buildLikeCountMap","line":162,"end_line":174,"hash":"a82ca3b46d68426edbe25f176c4a6019ea5fb6abea4c5d5e84aff3b381f63c61"},{"id":"func/PostQuery.postTxidFromPostLike","name":"PostQuery.postTxidFromPostLike","line":176,"end_line":180,"hash":"da7f8d0c63dbc074fb25da1a31dde5075c2c3469b84a5c700bd4a2961879d8e9"},{"id":"func/PostQuery.getPostOrNull","name":"PostQuery.getPostOrNull","line":183,"end_line":190,"hash":"792ce2a8d5f19ed3d159c7af7e95c310e5b0c05cbef4de5be8f8f78403680b91"},{"id":"func/PostQuery.topLevelPostTxids","name":"PostQuery.topLevelPostTxids","line":194,"end_line":202,"hash":"0457d8b43b692d7bbfde283e63663d74542778d3893b45202fcb6ae0f1fc6776"},{"id":"func/PostQuery.scanRecentPostTxids","name":"PostQuery.scanRecentPostTxids","line":204,"end_line":219,"hash":"bed887c0eeec051e082657cac518bbd2f60df84bbb86c9d8aa76015194e7a73a"},{"id":"func/PostQuery.scanPostsByAddrTxidsAndCount","name":"PostQuery.scanPostsByAddrTxidsAndCount","line":225,"end_line":253,"hash":"a872d7a1f71ce1ba6a1dee46a820989cbbb7287dfc433051be1e15890dbc010b"},{"id":"func/PostQuery.scanPostsByAddrTxids","name":"PostQuery.scanPostsByAddrTxids","line":256,"end_line":259,"hash":"3498f2b9615e79b9472a5c7be26520c78db7c1661071c90a882f981d8acca477"},{"id":"func/PostQuery.loadPostsByTxids","name":"PostQuery.loadPostsByTxids","line":261,"end_line":277,"hash":"86b05107f3ecc240b2e734217cedf29ef44c48474bf31c1c8f75f2e4b4f84bea"},{"id":"func/PostQuery.countTopLevelPosts","name":"PostQuery.countTopLevelPosts","line":279,"end_line":290,"hash":"a26a6fdd12201de71965545f4113e3598f325fa4d96076289d371e4157c667ff"},{"id":"func/PostQuery.countTopLevelPostsByAddr","name":"PostQuery.countTopLevelPostsByAddr","line":293,"end_line":308,"hash":"6e12c0b66758cd0103ff6abad1379b3883bea82f084d2382991b4643dfb63360"}]}
// mutate4javascript-manifest-end
diff --git a/psf-memo-db/src/adapters/search-query.js b/psf-memo-db/src/adapters/search-query.js
new file mode 100644
index 0000000..e6497d0
--- /dev/null
+++ b/psf-memo-db/src/adapters/search-query.js
@@ -0,0 +1,142 @@
+/*
+ Adapter for case-insensitive substring search across posts and profiles.
+
+ - postsDb: raw post documents keyed by txid
+ - postParentsDb: child-txid -> parent mapping used to exclude replies
+ - namesDb: address -> { name, ... } used for profile-name search
+ - profilesDb: address -> { text, ... } used for profile-bio search
+*/
+
+import { normalizeQuery } from '../lib/search.js'
+import { loadReplyTxids } from './lib/load-reply-txids.js'
+
+class SearchQuery {
+ constructor (localConfig = {}) {
+ const { postsDb, postParentsDb, namesDb, profilesDb } = localConfig
+ if (!postsDb) {
+ throw new Error('postsDb required when instantiating SearchQuery adapter.')
+ }
+ if (!postParentsDb) {
+ throw new Error('postParentsDb required when instantiating SearchQuery adapter.')
+ }
+ if (!namesDb) {
+ throw new Error('namesDb required when instantiating SearchQuery adapter.')
+ }
+ if (!profilesDb) {
+ throw new Error('profilesDb required when instantiating SearchQuery adapter.')
+ }
+ this.postsDb = postsDb
+ this.postParentsDb = postParentsDb
+ this.namesDb = namesDb
+ this.profilesDb = profilesDb
+
+ this.searchPosts = this.searchPosts.bind(this)
+ this.searchProfiles = this.searchProfiles.bind(this)
+ this.profileMatches = this.profileMatches.bind(this)
+ }
+
+ async searchPosts (query) {
+ const normalized = normalizeQuery(query)
+ if (normalized.length === 0) return []
+
+ const replyTxids = await loadReplyTxids(this.postParentsDb)
+ const matches = []
+
+ for await (const [txid, post] of this.postsDb.iterator()) {
+ if (this.isMatchingPost(txid, post, replyTxids, normalized)) {
+ matches.push({
+ txid,
+ addr: post.addr,
+ text: post.text,
+ seen: post.seen,
+ blockHeight: post.blockHeight ?? 0
+ })
+ }
+ }
+
+ return matches
+ }
+
+ isMatchingPost (txid, post, replyTxids, normalized) {
+ if (replyTxids.has(txid)) return false
+ if (!post || typeof post.text !== 'string') return false
+ return post.text.toLowerCase().includes(normalized)
+ }
+
+ async searchProfiles (query) {
+ const normalized = normalizeQuery(query)
+ if (normalized.length === 0) return []
+
+ const names = await this.loadProfileRecords(this.namesDb, 'name')
+ const profiles = await this.loadProfileRecords(this.profilesDb, 'text')
+ const matches = this.matchByName(names, profiles, normalized)
+ this.matchByText(names, profiles, normalized, matches)
+ return Array.from(matches.values())
+ }
+
+ async loadProfileRecords (db, field) {
+ return this.loadRecords(db, (record) => ({
+ [field]: record[field],
+ txid: record.txid,
+ seen: record.seen,
+ blockHeight: record.blockHeight ?? 0
+ }))
+ }
+
+ async loadRecords (db, mapper) {
+ const records = new Map()
+ for await (const [addr, record] of db.iterator()) {
+ if (!record) continue
+ records.set(addr, mapper(record))
+ }
+ return records
+ }
+
+ matchByName (names, profiles, normalized) {
+ const matches = new Map()
+ for (const [addr, nameRecord] of names.entries()) {
+ if (typeof nameRecord.name === 'string' && nameRecord.name.toLowerCase().includes(normalized)) {
+ const profileRecord = profiles.get(addr) || {}
+ matches.set(addr, this.profileMatches(addr, nameRecord, profileRecord))
+ }
+ }
+ return matches
+ }
+
+ matchByText (names, profiles, normalized, matches) {
+ for (const [addr, profileRecord] of profiles.entries()) {
+ if (this.textMatches(profileRecord, normalized) && !matches.has(addr)) {
+ matches.set(addr, this.profileMatches(addr, names.get(addr) || {}, profileRecord))
+ }
+ }
+ }
+
+ textMatches (profileRecord, normalized) {
+ return typeof profileRecord.text === 'string' && profileRecord.text.toLowerCase().includes(normalized)
+ }
+
+ profileMatches (addr, nameRecord, profileRecord) {
+ const blockHeight = Math.max(
+ nameRecord.blockHeight ?? 0,
+ profileRecord.blockHeight ?? 0
+ )
+ const seen = Math.max(
+ nameRecord.seen ?? 0,
+ profileRecord.seen ?? 0
+ )
+ return {
+ addr,
+ name: nameRecord.name || null,
+ text: profileRecord.text || null,
+ txid: nameRecord.txid || profileRecord.txid || null,
+ seen,
+ blockHeight
+ }
+ }
+}
+
+export default SearchQuery
+
+// mutate4javascript-manifest-begin
+// {"version":1,"tested_at":"2026-08-29T15:24:14.179Z","module_hash":"0c6f365ce684e05ae34df5de2fcacaad023d8578a9e228c91a6d3b799f84c3c8","functions":[{"id":"func/SearchQuery.constructor","name":"SearchQuery.constructor","line":14,"end_line":36,"hash":"dc2625c23df7e50c35810379846377d4af23ea56470d7c9308631a74c415cb51"},{"id":"func/SearchQuery.searchPosts","name":"SearchQuery.searchPosts","line":38,"end_line":58,"hash":"d49a70791e72e76f82842226b1a314e803ca5fce772d78beeb73a99069a537ae"},{"id":"func/SearchQuery.isMatchingPost","name":"SearchQuery.isMatchingPost","line":60,"end_line":64,"hash":"1148a189ef5574cb4789a4b12889269212c4bbf97202bccd2fd817d9d40b51a0"},{"id":"func/SearchQuery.searchProfiles","name":"SearchQuery.searchProfiles","line":66,"end_line":75,"hash":"0570020fc12640e30918accca2ea28157fb9a686183f1e3855d209e840fce82a"},{"id":"func/SearchQuery.loadProfileRecords","name":"SearchQuery.loadProfileRecords","line":77,"end_line":84,"hash":"fb67ceac97d5f08e47eca5c136f0b4afe099c4f3425fd83c72e521f9b1871a27"},{"id":"func/SearchQuery.loadRecords","name":"SearchQuery.loadRecords","line":86,"end_line":93,"hash":"2382749d3be862a2b7713550e680f61d7ef64355632f9f3f0fbeeccbf947e13c"},{"id":"func/SearchQuery.matchByName","name":"SearchQuery.matchByName","line":95,"end_line":104,"hash":"5e6e821883e767ec1b3a2ff03d6e19b032d0e4b3d491b59ffe9ab7924abd8edf"},{"id":"func/SearchQuery.matchByText","name":"SearchQuery.matchByText","line":106,"end_line":112,"hash":"4889f4aba2e7c8951b6899a93b2ebefdea6a8b9ee74f4ef2c112c08e3458459c"},{"id":"func/SearchQuery.textMatches","name":"SearchQuery.textMatches","line":114,"end_line":116,"hash":"3db2504b793c5f6648537f1d63969cbe496b9f42c31d4d0060dd527417561129"},{"id":"func/SearchQuery.profileMatches","name":"SearchQuery.profileMatches","line":118,"end_line":135,"hash":"03af6e62f654560d8361ea0e3f508c53e8e7024e65132974c7202b69461441c6"}]}
+// mutate4javascript-manifest-end
diff --git a/psf-memo-db/src/controllers/rest-api/index.js b/psf-memo-db/src/controllers/rest-api/index.js
index 6689924..9be1a1d 100644
--- a/psf-memo-db/src/controllers/rest-api/index.js
+++ b/psf-memo-db/src/controllers/rest-api/index.js
@@ -10,6 +10,7 @@ import FollowRouter from './follow/index.js'
import MuteRouter from './mute/index.js'
import TopicsRouter from './topics/index.js'
import PollsRouter from './polls/index.js'
+import SearchRouter from './search/index.js'
class RESTControllers {
constructor (localConfig = {}) {
@@ -47,6 +48,9 @@ class RESTControllers {
const pollsRouter = new PollsRouter(dependencies)
pollsRouter.attach(app)
+
+ const searchRouter = new SearchRouter(dependencies)
+ searchRouter.attach(app)
}
}
diff --git a/psf-memo-db/src/controllers/rest-api/search/controller.js b/psf-memo-db/src/controllers/rest-api/search/controller.js
new file mode 100644
index 0000000..2b452c3
--- /dev/null
+++ b/psf-memo-db/src/controllers/rest-api/search/controller.js
@@ -0,0 +1,66 @@
+/*
+ REST API controller for /search routes.
+*/
+
+import wlogger from '../../../adapters/wlogger.js'
+
+class SearchRESTControllerLib {
+ constructor (localConfig = {}) {
+ this.adapters = localConfig.adapters
+ this.useCases = localConfig.useCases
+ if (!this.adapters) {
+ throw new Error('Adapters required for Search REST Controller.')
+ }
+ if (!this.useCases) {
+ throw new Error('Use Cases required for Search REST Controller.')
+ }
+
+ this.search = this.search.bind(this)
+ this.handleError = this.handleError.bind(this)
+ }
+
+ handleError (ctx, err) {
+ if (err.status) {
+ ctx.throw(err.status, err.message || err)
+ } else {
+ wlogger.error('Error in search controller: ', err)
+ ctx.throw(500, err.message || 'Internal server error')
+ }
+ }
+
+ /**
+ * @api {get} /search Search posts and profiles
+ * @apiPermission public
+ * @apiName Search
+ * @apiGroup REST Search
+ *
+ * @apiDescription Searches top-level posts and profiles by case-insensitive
+ * substring match. Empty queries and queries with no matches return an empty
+ * result set rather than an error.
+ *
+ * @apiQuery {String} q Search query
+ * @apiQuery {Number} [limit=100] Page size (max 100)
+ * @apiQuery {Number} [offset=0] Number of results to skip after sorting
+ *
+ * @apiExample Example usage:
+ * curl -X GET "localhost:5021/search?q=hello&limit=50&offset=0"
+ *
+ * @apiSuccess {Object[]} posts Array of matching post objects
+ * @apiSuccess {Object[]} profiles Array of matching profile objects
+ * @apiSuccess {Object} pagination Pagination metadata
+ */
+ async search (ctx) {
+ try {
+ const { q, limit, offset } = ctx.query
+ ctx.body = await this.useCases.searchAll.execute({ q, limit, offset })
+ } catch (err) {
+ this.handleError(ctx, err)
+ }
+ }
+}
+
+export default SearchRESTControllerLib
+
+// mutate4javascript-manifest-begin
+// {"version":1,"tested_at":"2026-08-29T15:29:31.872Z","module_hash":"754b83cf1a4999e989084ba332d88924472370a14df989910d85bcea04deb001","functions":[{"id":"func/SearchRESTControllerLib.constructor","name":"SearchRESTControllerLib.constructor","line":8,"end_line":20,"hash":"75f8c48a39c4449f5b8c8a455d1bc6cdcd762fb035018f75b5e808f093c70c7b"},{"id":"func/SearchRESTControllerLib.handleError","name":"SearchRESTControllerLib.handleError","line":22,"end_line":29,"hash":"d8a071b5d797e24bacd19f63ca74b8eda35dc8d1f4bed97b9e5d37af7bee6831"},{"id":"func/SearchRESTControllerLib.search","name":"SearchRESTControllerLib.search","line":52,"end_line":59,"hash":"814f10f06f8406a61e59aa3678794414a41e8ddecd9693f5c3fa92242349722e"}]}
+// mutate4javascript-manifest-end
diff --git a/psf-memo-db/src/controllers/rest-api/search/index.js b/psf-memo-db/src/controllers/rest-api/search/index.js
new file mode 100644
index 0000000..cac5994
--- /dev/null
+++ b/psf-memo-db/src/controllers/rest-api/search/index.js
@@ -0,0 +1,45 @@
+/*
+ REST API route for /search.
+*/
+
+import Router from 'koa-router'
+import SearchRESTControllerLib from './controller.js'
+
+class SearchRouter {
+ constructor (localConfig = {}) {
+ this.adapters = localConfig.adapters
+ this.useCases = localConfig.useCases
+ this.router = new Router({ prefix: '/search' })
+ this.basev1 = '/'
+ this.attach = this.attach.bind(this)
+ this.search = this.search.bind(this)
+ }
+
+ attach (app) {
+ if (!app) {
+ throw new Error('App object must be passed when attaching SearchRouter.')
+ }
+
+ const searchRESTController = new SearchRESTControllerLib({
+ adapters: this.adapters,
+ useCases: this.useCases
+ })
+
+ this.router.get(this.basev1, this.search(searchRESTController))
+
+ app.use(this.router.routes())
+ app.use(this.router.allowedMethods({ throw: true }))
+ }
+
+ search (searchRESTController) {
+ return async (ctx, next) => {
+ await searchRESTController.search(ctx, next)
+ }
+ }
+}
+
+export default SearchRouter
+
+// mutate4javascript-manifest-begin
+// {"version":1,"tested_at":"2026-08-29T15:20:33.643Z","module_hash":"a32dfbdaa55df013a2d49f84a3089f66f38021248e12efcb11407118c94c9bc6","functions":[{"id":"func/SearchRouter.constructor","name":"SearchRouter.constructor","line":9,"end_line":16,"hash":"1eb5af36a47b22f3a868e5b9919dd1014e3a02ad9433f8eb30dda1ea30fdc046"},{"id":"func/SearchRouter.attach","name":"SearchRouter.attach","line":18,"end_line":32,"hash":"5b86e45767e913c1917b019cc2a3a46a150e9830022064012d72c0d6157f0591"},{"id":"func/SearchRouter.search","name":"SearchRouter.search","line":34,"end_line":38,"hash":"5dc63a71388481ce121a1eb449311e6bf09f98ebded6ed8dd5ea62922f2297c0"}]}
+// mutate4javascript-manifest-end
diff --git a/psf-memo-db/src/lib/search.js b/psf-memo-db/src/lib/search.js
new file mode 100644
index 0000000..9f61675
--- /dev/null
+++ b/psf-memo-db/src/lib/search.js
@@ -0,0 +1,23 @@
+/*
+ Shared pure helpers for the search feature.
+
+ normalizeQuery normalizes a user query for case-insensitive substring
+ matching. sortByHeightDesc orders posts/profiles by block height descending,
+ breaking ties by seen timestamp. Both are shared by the search use case and
+ the search adapter so the behavior stays identical across callers.
+*/
+
+export function normalizeQuery (query) {
+ return String(query ?? '').trim().toLowerCase()
+}
+
+export function sortByHeightDesc (a, b) {
+ if (b.blockHeight !== a.blockHeight) {
+ return b.blockHeight - a.blockHeight
+ }
+ return (b.seen || 0) - (a.seen || 0)
+}
+
+// mutate4javascript-manifest-begin
+// {"version":1,"tested_at":"2026-08-29T15:18:40.416Z","module_hash":"de3b455b6e4376f7a860f74318a42fbe21734f8be5cbcd1a2c261d7c67d1910d","functions":[{"id":"func/normalizeQuery","name":"normalizeQuery","line":10,"end_line":12,"hash":"8257496a9daf753f6928fc7ae709f319231a86dbbd4058727346e6be8cd12896"},{"id":"func/sortByHeightDesc","name":"sortByHeightDesc","line":14,"end_line":19,"hash":"6c1f80c4b6362b75769a45106c24df2dabfd885dec67acd6d3226729905d7c43"}]}
+// mutate4javascript-manifest-end
diff --git a/psf-memo-db/src/use-cases/index.js b/psf-memo-db/src/use-cases/index.js
index 9955d64..2022e09 100644
--- a/psf-memo-db/src/use-cases/index.js
+++ b/psf-memo-db/src/use-cases/index.js
@@ -18,6 +18,7 @@ import ListTopicFollowers from './list-topic-followers.js'
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'
class UseCases {
constructor (localConfig = {}) {
@@ -45,6 +46,7 @@ class UseCases {
this.getPoll = null
this.getPollOptions = null
this.getPollVotes = null
+ this.searchAll = null
}
async start () {
@@ -112,6 +114,10 @@ class UseCases {
adapters: this.adapters
})
+ this.searchAll = new SearchAll({
+ adapters: this.adapters
+ })
+
console.log('Use cases initialized.')
return true
diff --git a/psf-memo-db/src/use-cases/list-recent-profiles.js b/psf-memo-db/src/use-cases/list-recent-profiles.js
index 8664432..294936f 100644
--- a/psf-memo-db/src/use-cases/list-recent-profiles.js
+++ b/psf-memo-db/src/use-cases/list-recent-profiles.js
@@ -4,27 +4,19 @@
import { parseLimit, parseOffset } from './lib/pagination.js'
import { ListUseCase } from './lib/use-case.js'
+import { sortByHeightDesc } from '../lib/search.js'
class ListRecentProfiles extends ListUseCase {
constructor (localConfig = {}) {
super(localConfig, { useCaseName: 'ListRecentProfiles', adapterName: 'profileQuery' })
}
- sortProfiles (profiles) {
- return profiles.sort((a, b) => {
- if (b.blockHeight !== a.blockHeight) {
- return b.blockHeight - a.blockHeight
- }
- return (b.seen || 0) - (a.seen || 0)
- })
- }
-
async execute (inObj = {}) {
const limit = parseLimit(inObj.limit)
const offset = parseOffset(inObj.offset)
const allProfiles = await this.adapters.profileQuery.scanProfilesWithBlockHeight()
- const sorted = this.sortProfiles(allProfiles)
+ const sorted = allProfiles.sort(sortByHeightDesc)
const total = sorted.length
const profiles = sorted.slice(offset, offset + limit)
@@ -43,5 +35,5 @@ class ListRecentProfiles extends ListUseCase {
export default ListRecentProfiles
// mutate4javascript-manifest-begin
-// {"version":1,"tested_at":"2026-08-26T18:15:31.955Z","module_hash":"0a41c7c2086270d2f29266eb20b2313d2ab8782fc8a32ad6dd0b27d737a41b13","functions":[{"id":"func/ListRecentProfiles.constructor","name":"ListRecentProfiles.constructor","line":9,"end_line":11,"hash":"a2c7dd0696ac463cbc142fa7ccce4a3cadf8e246a872261733d199dbd4c153d1"},{"id":"func/ListRecentProfiles.sortProfiles","name":"ListRecentProfiles.sortProfiles","line":13,"end_line":20,"hash":"8dbe8da4b9a5c52230b5f865a6d0b5a5817a266277a72b09b93b2b0f76414d9e"},{"id":"func/ListRecentProfiles.execute","name":"ListRecentProfiles.execute","line":22,"end_line":40,"hash":"d14afac95c39e7a311d9e3a1243b6a326de38906b5ad312f014ce6b6d5459de1"}]}
+// {"version":1,"tested_at":"2026-08-29T14:25:23.929Z","module_hash":"4eee5bdcc428b107755e33bd6b2b6dd6477a01aa9feece327ba144ec4825442f","functions":[{"id":"func/ListRecentProfiles.constructor","name":"ListRecentProfiles.constructor","line":10,"end_line":12,"hash":"a2c7dd0696ac463cbc142fa7ccce4a3cadf8e246a872261733d199dbd4c153d1"},{"id":"func/ListRecentProfiles.execute","name":"ListRecentProfiles.execute","line":14,"end_line":32,"hash":"f9fc78d274ac88aad570a32a45d20364974dde79538b414823241610cf2339dc"}]}
// mutate4javascript-manifest-end
diff --git a/psf-memo-db/src/use-cases/search-all.js b/psf-memo-db/src/use-cases/search-all.js
new file mode 100644
index 0000000..e551dba
--- /dev/null
+++ b/psf-memo-db/src/use-cases/search-all.js
@@ -0,0 +1,73 @@
+/*
+ Use case: search across posts and profiles by case-insensitive substring.
+
+ Returns a page of matching top-level posts and profiles sharing the same
+ pagination parameters. Empty or whitespace-only queries produce an empty
+ result set without error.
+*/
+
+import { parseLimit, parseOffset } from './lib/pagination.js'
+import { ListUseCase } from './lib/use-case.js'
+import { normalizeQuery, sortByHeightDesc } from '../lib/search.js'
+
+class SearchAll extends ListUseCase {
+ constructor (localConfig = {}) {
+ super(localConfig, { useCaseName: 'SearchAll', adapterName: 'searchQuery' })
+ }
+
+ async execute (inObj = {}) {
+ const q = normalizeQuery(inObj.q)
+ const limit = parseLimit(inObj.limit)
+ const offset = parseOffset(inObj.offset)
+
+ if (q.length === 0) {
+ return this.emptyResult(limit, offset)
+ }
+
+ const [allPosts, allProfiles] = await Promise.all([
+ this.adapters.searchQuery.searchPosts(q),
+ this.adapters.searchQuery.searchProfiles(q)
+ ])
+
+ allPosts.sort(sortByHeightDesc)
+ allProfiles.sort(sortByHeightDesc)
+
+ const totalPosts = allPosts.length
+ const totalProfiles = allProfiles.length
+ const total = totalPosts + totalProfiles
+
+ const posts = allPosts.slice(offset, offset + limit)
+ const profiles = allProfiles.slice(offset, offset + limit)
+ const returnedCount = posts.length + profiles.length
+
+ return {
+ posts,
+ profiles,
+ pagination: {
+ limit,
+ offset,
+ total,
+ hasMore: offset + returnedCount < total
+ }
+ }
+ }
+
+ emptyResult (limit, offset) {
+ return {
+ posts: [],
+ profiles: [],
+ pagination: {
+ limit,
+ offset,
+ total: 0,
+ hasMore: false
+ }
+ }
+ }
+}
+
+export default SearchAll
+
+// mutate4javascript-manifest-begin
+// {"version":1,"tested_at":"2026-08-29T15:24:14.116Z","module_hash":"c6145beec460f82393ed11ff0d01b72696f557e0e40c2b991ca15e45334cf150","functions":[{"id":"func/SearchAll.constructor","name":"SearchAll.constructor","line":14,"end_line":16,"hash":"adc0dcca1ed5263c0f1e13e8a19657e332982ca6e4f2093080a6a970901ed4d3"},{"id":"func/SearchAll.execute","name":"SearchAll.execute","line":18,"end_line":53,"hash":"ade6f878cca2e20b6e8500cbc6f5650de797767520b31c0d1c29c43762e4ad53"},{"id":"func/SearchAll.emptyResult","name":"SearchAll.emptyResult","line":55,"end_line":66,"hash":"518a3c1be0814ec39ef86770db6f462dfb52bbb622e72b913cb9313cf3b7cc33"}]}
+// mutate4javascript-manifest-end
diff --git a/psf-memo-db/test/property/search-all.property.test.js b/psf-memo-db/test/property/search-all.property.test.js
new file mode 100644
index 0000000..98b33ed
--- /dev/null
+++ b/psf-memo-db/test/property/search-all.property.test.js
@@ -0,0 +1,132 @@
+/*
+ Property tests for the SearchAll use case.
+
+ The unit tests probe a few fixed fixtures. These properties cover broad
+ random inputs so the invariants hold everywhere:
+
+ - pagination consistency: total equals the combined result count and
+ hasMore matches the offset/returned-count arithmetic.
+ - empty query: an empty or whitespace query yields an empty result with
+ total 0 and hasMore false.
+ - ordering: returned posts and profiles are sorted by block height
+ descending.
+ - query normalization: the trimmed, lowercased query is passed to the
+ adapter.
+*/
+
+import test from 'node:test'
+import { seededRandom, forAll, intGen } from './harness.js'
+import SearchAll from '../../src/use-cases/search-all.js'
+
+const rng = seededRandom(20260831)
+
+function recordGen (kind) {
+ const n = intGen(rng, 0, 10)()
+ const records = []
+ for (let i = 0; i < n; i++) {
+ const base = {
+ seen: intGen(rng, 0, 1000)(),
+ blockHeight: intGen(rng, 0, 1000000)()
+ }
+ if (kind === 'post') {
+ records.push({ txid: `tx${i}`, addr: `addr${i}`, text: `text ${i}`, ...base })
+ } else {
+ records.push({ addr: `addr${i}`, name: `name ${i}`, text: `bio ${i}`, ...base })
+ }
+ }
+ return records
+}
+
+function makeUseCase (posts, profiles) {
+ return new SearchAll({
+ adapters: {
+ searchQuery: {
+ searchPosts: async () => posts,
+ searchProfiles: async () => profiles
+ }
+ }
+ })
+}
+
+function isSortedDesc (records) {
+ for (let i = 1; i < records.length; i++) {
+ if (records[i - 1].blockHeight < records[i].blockHeight) return false
+ }
+ return true
+}
+
+test('pagination metadata is consistent with the result set', async () => {
+ await forAll(
+ (i) => ({ posts: recordGen('post'), profiles: recordGen('profile') }),
+ async ({ posts, profiles }) => {
+ const uut = makeUseCase(posts, profiles)
+ const limit = intGen(rng, 1, 100)()
+ const offset = intGen(rng, 0, 20)()
+ const result = await uut.execute({ q: 'x', limit, offset })
+
+ const total = posts.length + profiles.length
+ const returnedCount = result.posts.length + result.profiles.length
+ const hasMore = offset + returnedCount < total
+
+ return result.pagination.total === total &&
+ result.pagination.hasMore === hasMore &&
+ result.pagination.limit === limit &&
+ result.pagination.offset === offset
+ },
+ { label: 'pagination consistency' }
+ )
+})
+
+test('empty and whitespace queries return an empty result', async () => {
+ await forAll(
+ (i) => ({ posts: recordGen('post'), profiles: recordGen('profile') }),
+ async ({ posts, profiles }) => {
+ const uut = makeUseCase(posts, profiles)
+ const empty = await uut.execute({ q: '' })
+ const whitespace = await uut.execute({ q: ' ' })
+
+ return empty.posts.length === 0 &&
+ empty.profiles.length === 0 &&
+ empty.pagination.total === 0 &&
+ empty.pagination.hasMore === false &&
+ whitespace.posts.length === 0 &&
+ whitespace.pagination.total === 0
+ },
+ { label: 'empty query result' }
+ )
+})
+
+test('returned posts and profiles are sorted by block height descending', async () => {
+ await forAll(
+ (i) => ({ posts: recordGen('post'), profiles: recordGen('profile') }),
+ async ({ posts, profiles }) => {
+ const uut = makeUseCase(posts, profiles)
+ const result = await uut.execute({ q: 'x', limit: 100, offset: 0 })
+
+ return isSortedDesc(result.posts) && isSortedDesc(result.profiles)
+ },
+ { label: 'block height ordering' }
+ )
+})
+
+test('the trimmed, lowercased query is passed to the adapter', async () => {
+ await forAll(
+ (i) => ({ posts: recordGen('post'), profiles: recordGen('profile') }),
+ async ({ posts, profiles }) => {
+ let receivedPosts = null
+ let receivedProfiles = null
+ const uut = new SearchAll({
+ adapters: {
+ searchQuery: {
+ searchPosts: async (q) => { receivedPosts = q; return posts },
+ searchProfiles: async (q) => { receivedProfiles = q; return profiles }
+ }
+ }
+ })
+ await uut.execute({ q: ' HeLLo ' })
+
+ return receivedPosts === 'hello' && receivedProfiles === 'hello'
+ },
+ { label: 'query normalization' }
+ )
+})
diff --git a/psf-memo-db/test/property/search-query.property.test.js b/psf-memo-db/test/property/search-query.property.test.js
new file mode 100644
index 0000000..7301748
--- /dev/null
+++ b/psf-memo-db/test/property/search-query.property.test.js
@@ -0,0 +1,175 @@
+/*
+ Property tests for the SearchQuery adapter.
+
+ The unit tests probe a few fixed fixtures. These properties cover broad
+ random record sets so the invariants hold everywhere:
+
+ - case-insensitivity: searching with a differently-cased query returns the
+ same result set as the lowercase query.
+ - reply exclusion: post search never returns a reply txid.
+ - substring containment: every returned post's text contains the query.
+ - empty query: empty and whitespace-only queries return no results.
+ - profile matching: every returned profile matches by name or bio.
+*/
+
+import test from 'node:test'
+import { seededRandom, forAll, intGen, txidGen } from './harness.js'
+import SearchQuery from '../../src/adapters/search-query.js'
+
+const rng = seededRandom(20260830)
+
+const WORDS = ['hello', 'bitcoin', 'cash', 'memo', 'protocol', 'trout', 'alice', 'bob', 'block', 'chain']
+
+function randomText (rng) {
+ const n = intGen(rng, 1, 4)()
+ const words = []
+ for (let i = 0; i < n; i++) {
+ words.push(WORDS[Math.floor(rng() * WORDS.length)])
+ }
+ return words.join(' ')
+}
+
+function makeIterator (entries) {
+ return async function * () {
+ for (const entry of entries) {
+ yield entry
+ }
+ }
+}
+
+function makeDb (entries) {
+ return { iterator: () => makeIterator(entries)() }
+}
+
+// Build a random set of posts (as [txid, post] pairs), some of which are replies.
+function postSetGen () {
+ const posts = []
+ const n = intGen(rng, 0, 8)()
+ for (let i = 0; i < n; i++) {
+ const txid = txidGen(rng)
+ posts.push([txid, {
+ addr: `addr${i}`,
+ text: randomText(rng),
+ seen: intGen(rng, 0, 1000)(),
+ blockHeight: intGen(rng, 0, 1000000)()
+ }])
+ }
+ const replyTxids = new Set()
+ const parents = []
+ for (const [txid] of posts) {
+ if (rng() < 0.3) {
+ replyTxids.add(txid)
+ parents.push([txid, { parentTxid: 'parent' }])
+ }
+ }
+ return { posts, parents, replyTxids }
+}
+
+// Build a random set of names and profiles (as [addr, record] pairs).
+function profileSetGen () {
+ const names = []
+ const profiles = []
+ const n = intGen(rng, 0, 8)()
+ for (let i = 0; i < n; i++) {
+ const addr = `addr${i}`
+ if (rng() < 0.7) {
+ names.push([addr, { name: randomText(rng), txid: txidGen(rng), seen: intGen(rng, 0, 1000)(), blockHeight: intGen(rng, 0, 1000000)() }])
+ }
+ if (rng() < 0.7) {
+ profiles.push([addr, { text: randomText(rng), txid: txidGen(rng), seen: intGen(rng, 0, 1000)(), blockHeight: intGen(rng, 0, 1000000)() }])
+ }
+ }
+ return { names, profiles }
+}
+
+function makeQuery (posts, parents, names, profiles) {
+ return new SearchQuery({
+ postsDb: makeDb(posts),
+ postParentsDb: makeDb(parents),
+ namesDb: makeDb(names),
+ profilesDb: makeDb(profiles)
+ })
+}
+
+test('post search is case-insensitive', async () => {
+ await forAll(
+ (i) => postSetGen(),
+ async ({ posts, parents }) => {
+ const query = makeQuery(posts, parents, [], [])
+ const word = WORDS[Math.floor(rng() * WORDS.length)]
+ const a = (await query.searchPosts(word.toUpperCase())).map((p) => p.txid).sort()
+ const b = (await query.searchPosts(word.toLowerCase())).map((p) => p.txid).sort()
+ return JSON.stringify(a) === JSON.stringify(b)
+ },
+ { label: 'post search case-insensitivity' }
+ )
+})
+
+test('post search never returns reply txids', async () => {
+ await forAll(
+ (i) => postSetGen(),
+ async ({ posts, parents, replyTxids }) => {
+ const query = makeQuery(posts, parents, [], [])
+ const word = WORDS[Math.floor(rng() * WORDS.length)]
+ const results = await query.searchPosts(word)
+ return results.every((p) => !replyTxids.has(p.txid))
+ },
+ { label: 'post search reply exclusion' }
+ )
+})
+
+test('every returned post contains the query substring', async () => {
+ await forAll(
+ (i) => postSetGen(),
+ async ({ posts, parents }) => {
+ const query = makeQuery(posts, parents, [], [])
+ const word = WORDS[Math.floor(rng() * WORDS.length)]
+ const results = await query.searchPosts(word)
+ return results.every((p) => p.text.toLowerCase().includes(word.toLowerCase()))
+ },
+ { label: 'post search substring containment' }
+ )
+})
+
+test('empty and whitespace queries return no posts', async () => {
+ await forAll(
+ (i) => postSetGen(),
+ async ({ posts, parents }) => {
+ const query = makeQuery(posts, parents, [], [])
+ const empty = await query.searchPosts('')
+ const whitespace = await query.searchPosts(' ')
+ return empty.length === 0 && whitespace.length === 0
+ },
+ { label: 'empty post query' }
+ )
+})
+
+test('every returned profile matches by name or bio', async () => {
+ await forAll(
+ (i) => profileSetGen(),
+ async ({ names, profiles }) => {
+ const query = makeQuery([], [], names, profiles)
+ const word = WORDS[Math.floor(rng() * WORDS.length)]
+ const results = await query.searchProfiles(word)
+ return results.every((p) => {
+ const nameMatch = p.name && p.name.toLowerCase().includes(word.toLowerCase())
+ const textMatch = p.text && p.text.toLowerCase().includes(word.toLowerCase())
+ return nameMatch || textMatch
+ })
+ },
+ { label: 'profile search name/bio matching' }
+ )
+})
+
+test('empty and whitespace queries return no profiles', async () => {
+ await forAll(
+ (i) => profileSetGen(),
+ async ({ names, profiles }) => {
+ const query = makeQuery([], [], names, profiles)
+ const empty = await query.searchProfiles('')
+ const whitespace = await query.searchProfiles(' ')
+ return empty.length === 0 && whitespace.length === 0
+ },
+ { label: 'empty profile query' }
+ )
+})
diff --git a/psf-memo-db/test/unit/adapters/search-query.unit.js b/psf-memo-db/test/unit/adapters/search-query.unit.js
new file mode 100644
index 0000000..b2b6625
--- /dev/null
+++ b/psf-memo-db/test/unit/adapters/search-query.unit.js
@@ -0,0 +1,232 @@
+import { assert } from 'chai'
+import SearchQuery from '../../../src/adapters/search-query.js'
+
+function makeIterator (entries) {
+ return async function * () {
+ for (const entry of entries) {
+ yield entry
+ }
+ }
+}
+
+describe('#SearchQuery', () => {
+ let postsDb
+ let postParentsDb
+ let namesDb
+ let profilesDb
+
+ beforeEach(() => {
+ postsDb = { iterator: () => makeIterator([])() }
+ postParentsDb = { iterator: () => makeIterator([])() }
+ namesDb = { iterator: () => makeIterator([])() }
+ profilesDb = { iterator: () => makeIterator([])() }
+ })
+
+ it('should require postsDb', () => {
+ assert.throws(() => {
+ return new SearchQuery({ postParentsDb, namesDb, profilesDb })
+ }, /postsDb required/)
+ })
+
+ it('should require postParentsDb', () => {
+ assert.throws(() => {
+ return new SearchQuery({ postsDb, namesDb, profilesDb })
+ }, /postParentsDb required/)
+ })
+
+ it('should require namesDb', () => {
+ assert.throws(() => {
+ return new SearchQuery({ postsDb, postParentsDb, profilesDb })
+ }, /namesDb required/)
+ })
+
+ it('should require profilesDb', () => {
+ assert.throws(() => {
+ return new SearchQuery({ postsDb, postParentsDb, namesDb })
+ }, /profilesDb required/)
+ })
+
+ it('should match top-level posts by text substring case-insensitively', async () => {
+ postsDb.iterator = () => makeIterator([
+ ['tx1', { addr: 'addr1', text: 'hello world', seen: 100, blockHeight: 600100 }],
+ ['tx2', { addr: 'addr2', text: 'bitcoin cash', seen: 200, blockHeight: 600200 }]
+ ])()
+ postParentsDb.iterator = () => makeIterator([])()
+
+ const uut = new SearchQuery({ postsDb, postParentsDb, namesDb, profilesDb })
+ const result = await uut.searchPosts('HELLO')
+
+ assert.equal(result.length, 1)
+ assert.equal(result[0].txid, 'tx1')
+ assert.equal(result[0].text, 'hello world')
+ })
+
+ it('should exclude replies from post search results', async () => {
+ postsDb.iterator = () => makeIterator([
+ ['tx1', { addr: 'addr1', text: 'hello world', seen: 100, blockHeight: 600100 }],
+ ['tx2', { addr: 'addr2', text: 'hello reply', seen: 200, blockHeight: 600200 }]
+ ])()
+ postParentsDb.iterator = () => makeIterator([
+ ['tx2', { parentTxid: 'tx1' }]
+ ])()
+
+ const uut = new SearchQuery({ postsDb, postParentsDb, namesDb, profilesDb })
+ const result = await uut.searchPosts('hello')
+
+ assert.equal(result.length, 1)
+ assert.equal(result[0].txid, 'tx1')
+ })
+
+ it('should match profiles by name case-insensitively', async () => {
+ namesDb.iterator = () => makeIterator([
+ ['addr1', { name: 'Alice Trout', txid: 'tx1', seen: 100, blockHeight: 600100 }],
+ ['addr2', { name: 'Bob Builder', txid: 'tx2', seen: 200, blockHeight: 600200 }]
+ ])()
+ profilesDb.iterator = () => makeIterator([])()
+
+ const uut = new SearchQuery({ postsDb, postParentsDb, namesDb, profilesDb })
+ const result = await uut.searchProfiles('alice')
+
+ assert.equal(result.length, 1)
+ assert.equal(result[0].addr, 'addr1')
+ assert.equal(result[0].name, 'Alice Trout')
+ })
+
+ it('should match profiles by bio case-insensitively', async () => {
+ namesDb.iterator = () => makeIterator([])()
+ profilesDb.iterator = () => makeIterator([
+ ['addr1', { text: 'bitcoin cash enthusiast', txid: 'tx1', seen: 100, blockHeight: 600100 }],
+ ['addr2', { text: 'building on BCH', txid: 'tx2', seen: 200, blockHeight: 600200 }]
+ ])()
+
+ const uut = new SearchQuery({ postsDb, postParentsDb, namesDb, profilesDb })
+ const result = await uut.searchProfiles('enthusiast')
+
+ assert.equal(result.length, 1)
+ assert.equal(result[0].addr, 'addr1')
+ assert.equal(result[0].text, 'bitcoin cash enthusiast')
+ })
+
+ it('should return profile name and bio together when both exist', async () => {
+ namesDb.iterator = () => makeIterator([
+ ['addr1', { name: 'Alice Trout', txid: 'tx1', seen: 100, blockHeight: 600100 }]
+ ])()
+ profilesDb.iterator = () => makeIterator([
+ ['addr1', { text: 'bitcoin cash enthusiast', txid: 'tx2', seen: 200, blockHeight: 600200 }]
+ ])()
+
+ const uut = new SearchQuery({ postsDb, postParentsDb, namesDb, profilesDb })
+ const result = await uut.searchProfiles('bitcoin')
+
+ assert.equal(result.length, 1)
+ assert.equal(result[0].name, 'Alice Trout')
+ assert.equal(result[0].text, 'bitcoin cash enthusiast')
+ })
+
+ it('should return no posts when query is empty', async () => {
+ postsDb.iterator = () => makeIterator([
+ ['tx1', { addr: 'addr1', text: 'hello world', seen: 100, blockHeight: 600100 }]
+ ])()
+ postParentsDb.iterator = () => makeIterator([])()
+
+ const uut = new SearchQuery({ postsDb, postParentsDb, namesDb, profilesDb })
+ const result = await uut.searchPosts('')
+
+ assert.equal(result.length, 0)
+ })
+
+ it('should return no profiles when query is empty', async () => {
+ namesDb.iterator = () => makeIterator([
+ ['addr1', { name: 'Alice Trout', txid: 'tx1', seen: 100, blockHeight: 600100 }]
+ ])()
+ profilesDb.iterator = () => makeIterator([])()
+
+ const uut = new SearchQuery({ postsDb, postParentsDb, namesDb, profilesDb })
+ const result = await uut.searchProfiles('')
+
+ assert.equal(result.length, 0)
+ })
+
+ it('should return a match for a single-character query', async () => {
+ postsDb.iterator = () => makeIterator([
+ ['tx1', { addr: 'addr1', text: 'hello world', seen: 100, blockHeight: 600100 }]
+ ])()
+ postParentsDb.iterator = () => makeIterator([])()
+
+ const uut = new SearchQuery({ postsDb, postParentsDb, namesDb, profilesDb })
+ const result = await uut.searchPosts('h')
+
+ assert.equal(result.length, 1)
+ assert.equal(result[0].txid, 'tx1')
+ })
+
+ it('should skip a null post without throwing', async () => {
+ postsDb.iterator = () => makeIterator([
+ ['tx1', null],
+ ['tx2', { addr: 'addr2', text: 'hello world', seen: 100, blockHeight: 600100 }]
+ ])()
+ postParentsDb.iterator = () => makeIterator([])()
+
+ const uut = new SearchQuery({ postsDb, postParentsDb, namesDb, profilesDb })
+ const result = await uut.searchPosts('hello')
+
+ assert.equal(result.length, 1)
+ assert.equal(result[0].txid, 'tx2')
+ })
+
+ it('should exclude posts that do not match the query', async () => {
+ postsDb.iterator = () => makeIterator([
+ ['tx1', { addr: 'addr1', text: 'hello world', seen: 100, blockHeight: 600100 }]
+ ])()
+ postParentsDb.iterator = () => makeIterator([])()
+
+ const uut = new SearchQuery({ postsDb, postParentsDb, namesDb, profilesDb })
+ const result = await uut.searchPosts('zzz')
+
+ assert.equal(result.length, 0)
+ })
+
+ it('should default blockHeight to 0 for a profile record without one', async () => {
+ namesDb.iterator = () => makeIterator([])()
+ profilesDb.iterator = () => makeIterator([
+ ['addr1', { text: 'bitcoin cash', txid: 'tx1', seen: 100 }]
+ ])()
+
+ const uut = new SearchQuery({ postsDb, postParentsDb, namesDb, profilesDb })
+ const result = await uut.searchProfiles('bitcoin')
+
+ assert.equal(result.length, 1)
+ assert.equal(result[0].blockHeight, 0)
+ })
+
+ it('should default blockHeight and seen to 0 when profile records lack them', async () => {
+ namesDb.iterator = () => makeIterator([
+ ['addr1', { name: 'Alice', txid: 'tx1' }]
+ ])()
+ profilesDb.iterator = () => makeIterator([
+ ['addr1', { text: 'bitcoin cash' }]
+ ])()
+
+ const uut = new SearchQuery({ postsDb, postParentsDb, namesDb, profilesDb })
+ const result = await uut.searchProfiles('bitcoin')
+
+ assert.equal(result.length, 1)
+ assert.equal(result[0].blockHeight, 0)
+ assert.equal(result[0].seen, 0)
+ })
+
+ it('should fall back to the profile txid when the name record has none', async () => {
+ namesDb.iterator = () => makeIterator([
+ ['addr1', { name: 'Alice' }]
+ ])()
+ profilesDb.iterator = () => makeIterator([
+ ['addr1', { text: 'bitcoin cash', txid: 'tx2', seen: 100, blockHeight: 600100 }]
+ ])()
+
+ const uut = new SearchQuery({ postsDb, postParentsDb, namesDb, profilesDb })
+ const result = await uut.searchProfiles('bitcoin')
+
+ assert.equal(result.length, 1)
+ assert.equal(result[0].txid, 'tx2')
+ })
+})
diff --git a/psf-memo-db/test/unit/controllers/search.controller.unit.js b/psf-memo-db/test/unit/controllers/search.controller.unit.js
new file mode 100644
index 0000000..0f31598
--- /dev/null
+++ b/psf-memo-db/test/unit/controllers/search.controller.unit.js
@@ -0,0 +1,76 @@
+import { assert } from 'chai'
+import sinon from 'sinon'
+import SearchRESTControllerLib from '../../../src/controllers/rest-api/search/controller.js'
+
+describe('#SearchRESTController', () => {
+ let uut
+ let sandbox
+
+ beforeEach(() => {
+ sandbox = sinon.createSandbox()
+ uut = new SearchRESTControllerLib({
+ adapters: {},
+ useCases: {
+ searchAll: {
+ execute: sandbox.stub().resolves({
+ posts: [{ txid: 'tx1', text: 'hello' }],
+ profiles: [{ addr: 'addr1', name: 'Alice' }],
+ pagination: { limit: 100, offset: 0, total: 2, hasMore: false }
+ })
+ }
+ }
+ })
+ })
+
+ afterEach(() => sandbox.restore())
+
+ it('should return search results from use case', async () => {
+ const ctx = { query: { q: 'hello', limit: '50', offset: '0' }, body: null, throw: sandbox.stub() }
+ await uut.search(ctx)
+
+ assert.equal(uut.useCases.searchAll.execute.callCount, 1)
+ assert.deepEqual(uut.useCases.searchAll.execute.firstCall.args[0], {
+ q: 'hello',
+ limit: '50',
+ offset: '0'
+ })
+ assert.equal(ctx.body.posts.length, 1)
+ assert.equal(ctx.body.profiles.length, 1)
+ assert.equal(ctx.body.pagination.total, 2)
+ })
+
+ it('should handle an empty query', async () => {
+ const ctx = { query: { q: '' }, body: null, throw: sandbox.stub() }
+ await uut.search(ctx)
+
+ assert.deepEqual(uut.useCases.searchAll.execute.firstCall.args[0], {
+ q: '',
+ limit: undefined,
+ offset: undefined
+ })
+ })
+
+ it('should rethrow a status error from the use case', async () => {
+ const err = new Error('bad request')
+ err.status = 400
+ uut.useCases.searchAll.execute.rejects(err)
+
+ const ctx = { query: { q: 'hello' }, body: null, throw: sandbox.stub() }
+ await uut.search(ctx)
+
+ assert.equal(ctx.throw.callCount, 1)
+ assert.equal(ctx.throw.firstCall.args[0], 400)
+ assert.equal(ctx.throw.firstCall.args[1], 'bad request')
+ })
+
+ it('should throw a 500 for an unexpected error', async () => {
+ uut.useCases.searchAll.execute.rejects(new Error('boom'))
+
+ const ctx = { query: { q: 'hello' }, body: null, throw: sandbox.stub() }
+ await uut.search(ctx)
+
+ assert.equal(ctx.throw.callCount, 1)
+ assert.equal(ctx.throw.firstCall.args[0], 500)
+ assert.equal(ctx.throw.firstCall.args[1], 'boom')
+ })
+})
diff --git a/psf-memo-db/test/unit/use-cases/search-all.unit.js b/psf-memo-db/test/unit/use-cases/search-all.unit.js
new file mode 100644
index 0000000..45873d8
--- /dev/null
+++ b/psf-memo-db/test/unit/use-cases/search-all.unit.js
@@ -0,0 +1,135 @@
+import { assert } from 'chai'
+import sinon from 'sinon'
+import SearchAll from '../../../src/use-cases/search-all.js'
+
+describe('#SearchAll', () => {
+ let uut
+ let sandbox
+ let searchQuery
+
+ beforeEach(() => {
+ sandbox = sinon.createSandbox()
+ searchQuery = {
+ searchPosts: sandbox.stub().resolves([]),
+ searchProfiles: sandbox.stub().resolves([])
+ }
+ uut = new SearchAll({
+ adapters: { searchQuery }
+ })
+ })
+
+ afterEach(() => sandbox.restore())
+
+ it('should require searchQuery adapter', () => {
+ assert.throws(() => {
+ return new SearchAll({ adapters: {} })
+ }, /searchQuery adapter required/)
+ })
+
+ it('should return empty results for an empty query', async () => {
+ const result = await uut.execute({ q: '' })
+
+ assert.deepEqual(result.posts, [])
+ assert.deepEqual(result.profiles, [])
+ assert.equal(result.pagination.total, 0)
+ assert.equal(result.pagination.hasMore, false)
+ })
+
+ it('should pass query to the adapter and return results', async () => {
+ searchQuery.searchPosts.resolves([
+ { txid: 'tx1', addr: 'addr1', text: 'hello world', seen: 100, blockHeight: 600100 }
+ ])
+ searchQuery.searchProfiles.resolves([
+ { addr: 'addr1', name: 'Alice Trout', text: 'bitcoin fan', seen: 100, blockHeight: 600100 }
+ ])
+
+ const result = await uut.execute({ q: 'hello' })
+
+ assert.equal(result.posts.length, 1)
+ assert.equal(result.posts[0].text, 'hello world')
+ assert.equal(result.profiles.length, 1)
+ assert.equal(result.profiles[0].name, 'Alice Trout')
+ assert.equal(searchQuery.searchPosts.firstCall.args[0], 'hello')
+ assert.equal(searchQuery.searchProfiles.firstCall.args[0], 'hello')
+ })
+
+ it('should default limit to 100 and offset to 0', async () => {
+ searchQuery.searchPosts.resolves([])
+ searchQuery.searchProfiles.resolves([])
+
+ const result = await uut.execute({ q: 'test' })
+
+ assert.equal(result.pagination.limit, 100)
+ assert.equal(result.pagination.offset, 0)
+ })
+
+ it('should reject limit over 100', async () => {
+ try {
+ await uut.execute({ q: 'test', limit: 101 })
+ assert.fail('Expected error')
+ } catch (err) {
+ assert.equal(err.status, 400)
+ assert.include(err.message, 'limit cannot exceed')
+ }
+ })
+
+ it('should paginate results', async () => {
+ searchQuery.searchPosts.resolves([
+ { txid: 'tx1', addr: 'addr1', text: 'a', seen: 100, blockHeight: 600100 },
+ { txid: 'tx2', addr: 'addr2', text: 'b', seen: 200, blockHeight: 600200 }
+ ])
+ searchQuery.searchProfiles.resolves([
+ { addr: 'addr1', name: 'Alice', text: 'bio', seen: 100, blockHeight: 600100 },
+ { addr: 'addr2', name: 'Bob', text: 'bio', seen: 200, blockHeight: 600200 }
+ ])
+
+ const result = await uut.execute({ q: 'test', limit: 1, offset: 0 })
+
+ assert.equal(result.posts.length, 1)
+ assert.equal(result.profiles.length, 1)
+ assert.equal(result.pagination.limit, 1)
+ assert.equal(result.pagination.offset, 0)
+ assert.equal(result.pagination.total, 4)
+ assert.equal(result.pagination.hasMore, true)
+ })
+
+ it('should trim whitespace from query', async () => {
+ searchQuery.searchPosts.resolves([])
+ searchQuery.searchProfiles.resolves([])
+
+ await uut.execute({ q: ' hello ' })
+
+ assert.equal(searchQuery.searchPosts.firstCall.args[0], 'hello')
+ assert.equal(searchQuery.searchProfiles.firstCall.args[0], 'hello')
+ })
+
+ it('should search for a single-character query', async () => {
+ searchQuery.searchPosts.resolves([
+ { txid: 'tx1', addr: 'addr1', text: 'hello', seen: 100, blockHeight: 600100 }
+ ])
+ searchQuery.searchProfiles.resolves([])
+
+ const result = await uut.execute({ q: 'h' })
+
+ assert.equal(result.posts.length, 1)
+ assert.equal(searchQuery.searchPosts.firstCall.args[0], 'h')
+ })
+
+ it('should report hasMore false when the page reaches the end', async () => {
+ searchQuery.searchPosts.resolves([
+ { txid: 'tx1', addr: 'addr1', text: 'a', seen: 100, blockHeight: 600100 },
+ { txid: 'tx2', addr: 'addr2', text: 'b', seen: 200, blockHeight: 600200 }
+ ])
+ searchQuery.searchProfiles.resolves([
+ { addr: 'addr1', name: 'Alice', text: 'bio', seen: 100, blockHeight: 600100 },
+ { addr: 'addr2', name: 'Bob', text: 'bio', seen: 200, blockHeight: 600200 }
+ ])
+
+ const result = await uut.execute({ q: 'test', limit: 4, offset: 0 })
+
+ assert.equal(result.posts.length, 2)
+ assert.equal(result.profiles.length, 2)
+ assert.equal(result.pagination.total, 4)
+ assert.equal(result.pagination.hasMore, false)
+ })
+})