PRD: Post Search (community Search tab) #
Status: backend implemented 2026-09-01 via /tdd and hardened after /second-opinion on 2026-09-02 (§1 describes what shipped; §2 and §3 are still the plan) (10 red/green cycles, plan-reviewed). Clients cannot ship until the endpoint is deployed.
Goal: cross-community post search, social.coves.feed.searchPosts. One entity
type (posts), searched across every community this AppView indexes, with an
optional community filter. The first consumer is a Search tab on the
community page (mobile first, frontend second), which always passes
community. A client-side filter over loaded feed pages would only see the
15-50 posts in memory, so that is explicitly not the design.
Scope words, so nobody re-litigates them:
- Cross-community post search: this PRD. Posts only, all communities, optional community filter.
- Global search: a later product surface (Home search box) that fans out to
feed.searchPosts,community.search, and a futureactor.searchActorsand renders them as tabs. Not this PRD. - Network-wide: not a thing in atProto. An AppView searches only what it has indexed. That is also Bluesky's limit.
0. Decisions (read these first) #
| Decision | Choice | Why |
|---|---|---|
| NSID | social.coves.feed.searchPosts |
The namespace says what comes back, not what filters it: feed.getCommunity is already a community-scoped read of posts under feed.*. feed.* is the read-many namespace and is already published/delegated in DNS (docs/LEXICON_PUBLISHING.md). Mirrors app.bsky.feed.searchPosts. community.searchPosts was considered and rejected: it would sit beside community.search (which returns communities) and would read wrong once community is optional. |
| Old lexicon | Delete internal/atproto/lexicon/social/coves/community/post/search.json |
Never implemented and would duplicate the new query. It WAS published by the 2026-07 community.* sweep and still resolves on the network; decision 2026-09-01: keep the deletion and retire it with goat lex unpublish social.coves.community.post.search at the next publish (step added to docs/LEXICON_PUBLISHING.md). The community.post.* namespace itself is alive (create/update/delete/get/getStatus are served); it is the single-post CRUD surface, while feed.* is the read-many surface, which is where a search belongs. Only the community.post record ingestion was retired in ff53627. docs/PRD_POSTS.md:464 marks search done; fix that line. |
| Output key | feed, not posts |
Every feed.* query returns {feed: [feedViewPost], cursor}. Mobile TimelineResponse and frontend FeedResponse then parse search results with zero new code. |
community param |
Optional | Omitted: search every community, with exactly the predicate getDiscover uses (admission gate + viewer author-blocks). Present: add p.community_did = $n. Discover applies no community-level rules today, so the cross-community path costs one conditional clause. The community Search tab always passes it. |
| Sorts | relevance (default), new, top + timeframe |
new/top reuse the existing signed-cursor branches unchanged. hot is meaningless for search. |
| Limit | 1..50, default 15 | Same as every other feed query, so the service validation is shared. |
| Index | STORED generated tsvector column + GIN |
See §2. |
| Text search config | english |
Matches the commented-out index in migration 011 and the PRD. Posts do not store langs (only comments do), so per-language configs are not possible yet. Stemming non-English (Lemmy-bridged) text as English is mostly harmless. |
| Query parser | websearch_to_tsquery |
Never errors on arbitrary user input; supports "quoted phrases", -negation, OR. |
| Route path | /xrpc/social.coves.feed.searchPosts exactly |
Do not repeat the getCommunity drift (§1.6). |
1. Backend (coves) — implemented #
1.1 Lexicon #
New file internal/atproto/lexicon/social/coves/feed/searchPosts.json:
{
"lexicon": 1,
"id": "social.coves.feed.searchPosts",
"defs": {
"main": {
"type": "query",
"description": "Full-text search over visible posts, across every community or restricted to one. Results honour the same admission, deletion and viewer-block rules as social.coves.feed.getDiscover and social.coves.feed.getCommunity.",
"parameters": {
"type": "params",
"required": ["q"],
"properties": {
"q": { "type": "string", "maxLength": 500, "description": "Search query. Supports quoted phrases and -negation." },
"community": { "type": "string", "maxLength": 320, "description": "Restrict results to one community: DID, handle, name@origin, or bare local name. Omit to search every community." },
"sort": { "type": "string", "knownValues": ["relevance", "new", "top"], "default": "relevance", "maxLength": 64 },
"timeframe": { "type": "string", "knownValues": ["hour", "day", "week", "month", "year", "all"], "default": "all", "maxLength": 64 },
"limit": { "type": "integer", "minimum": 1, "maximum": 50, "default": 15 },
"cursor": { "type": "string", "maxLength": 500 }
}
},
"output": {
"encoding": "application/json",
"schema": {
"type": "object",
"required": ["feed"],
"properties": {
"feed": { "type": "array", "items": { "type": "ref", "ref": "social.coves.feed.defs#feedViewPost" } },
"cursor": { "type": "string", "maxLength": 500 }
}
}
}
}
}
}
tests/lexicon_fixtures_test.go loads the whole lexicon directory into an
indigo catalog, so the new file is validated at T0 automatically. Query
lexicons need no fixture. No //coves:ingestion-contract marker either; that
gate only covers jetstream.WantedCollections.
1.2 Migration 044_posts_search_vector.sql #
-- +goose Up
ALTER TABLE posts ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
setweight(to_tsvector('english', coalesce(content, '')), 'B')
) STORED;
CREATE INDEX idx_posts_search_vector ON posts USING gin(search_vector)
WHERE deleted_at IS NULL;
-- +goose Down
DROP INDEX IF EXISTS idx_posts_search_vector;
ALTER TABLE posts DROP COLUMN IF EXISTS search_vector;
Why a stored column rather than the expression index from migration 011:
- The planner only uses an expression index when the query repeats the expression byte-for-byte; a column removes that trap.
ts_rank_cdon a stored vector is cheap. Re-tokenising a 100k-char body per candidate row at rank time is not.- The generated column backfills itself during the migration, so no backfill
job and no consumer change. The postv2 consumer keeps writing
titleandcontentas today.
Cost: adding a STORED generated column rewrites the table under an ACCESS EXCLUSIVE lock. Fine at current volume; note it in the deploy.
Limits: tsvector caps at 1 MiB per row. The local write API caps content
at 100,000 bytes and title at 3,000, but the firehose indexes federated
records that never passed that API, so the generated expression is bounded
with left(coalesce(content, ''), 100000) / left(coalesce(title, ''), 3000)
and the post consumer rejects oversized records as permanent events
(MaxPostContentBytes / MaxPostTitleBytes in internal/atproto/jetstream).
ADD COLUMN ... GENERATED ... STORED rewrites posts under an ACCESS
EXCLUSIVE lock; expect a brief pause on deploy.
1.3 Repository (internal/db/postgres/feed_repo.go) #
Add SearchPosts(ctx, req) ([]*FeedViewPost, *string, error) next to
GetCommunityFeed, built from the same pieces:
feedPostSelectClause(...)+scanFeedPostfor hydration (no drift fromPostView).visiblePostsJoin(viewerParam)frompost_visibility.go. This is the only place the admission predicate is spelled; search MUST go through it, or a pending/removed post becomes findable by search while hidden from the feed.- The same
user_blocksNOT EXISTS filter andp.deleted_at IS NULL. - Extra predicate:
AND p.search_vector @@ websearch_to_tsquery('english', $q). - Community filter only when the request carries one:
AND p.community_did = $n. Without it the query isGetDiscoverplus the tsquery. Bind parameters are positional, so build the arg list and the numbering together (the feed repo hard-codes$1/$2; do not copy that). - Extra bind: the tsquery. Bind
qonce and referencewebsearch_to_tsquery('english', $n)from WHERE, ORDER BY, and the cursor filter.
Ordering and cursors:
relevance:ORDER BY rank DESC, p.created_at DESC, p.uri DESCwhererank = ts_rank_cd(p.search_vector, <tsquery>, 1)(normalisation 1 divides by log(length) so long bodies do not dominate). Rank is deterministic for a fixedq, so keyset pagination is stable. TherelevanceORDER BY lives infeedSortClausesbesidehot/new/top.- Every search cursor is a signed envelope
search::<scope hash>::<position>(parseSearchCursor/buildSearchCursorinfeed_repo_base.go). The scope hash is SHA-256 over the length-prefixedq, resolved community DID (or empty), sort, and timeframe (timeframe only whensort=top, since the other sorts ignore it). A cursor replayed with any of those changed is rejected withErrInvalidCursor;new/toppositions reuse the feed formats andrelevancecarriesrank::created_at::uri. The rank is a float4 that round-trips exactly through the cursor text (lib/pq forcesextra_float_digits=2), so the filter compares= $n::realdirectly. Cursors stay HMAC-signed withcfg.CursorSecret. - Degenerate queries are refused in SQL, not the service: stopword-only
(
"the") parses to an empty tsquery and negation-only (-rust, orfox OR -red, whichquerytreealso collapses toT) would match the whole corpus.querytree(websearch_to_tsquery('english', $q)) NOT IN ('', 'T')returns an empty feed with no cursor for both; never a 500. - Viewer blocks go through
viewerBlockFilters:aggregateSurfacewhencommunityis omitted (author blocks and community blocks apply, like Discover),explicitSurfacewhen scoped (author blocks only, like the community's own feed).
Fetch limit + 1 rows to decide whether to emit a cursor, as the feed does.
1.4 Service (internal/core/communityFeeds) #
types.go:SearchPostsRequest{Query, Community, ViewerDID, Sort, Timeframe, Limit, Cursor}.interfaces.go: addSearchPoststo bothServiceandRepository.service.go: validate (qtrimmed non-empty, byte length ≤ 500; sort in {relevance,new,top}; limit 1..50; timeframe as today), resolve the community viacommunityService.ResolveCommunityIdentifier(handles DID, handle,name@origin, bare name;ErrCommunityNotFoundon miss) only when the request carries one, then call the repo. ReusevalidateRequestpieces rather than copying them.
1.5 Handler, route, rate limit #
internal/api/handlers/communityFeed/search_posts.go: parse params, call the service, then the same post-processing asHandleGetCommunity(PopulateViewerVoteState,TransformBlobRefsToURLs,TransformPostEmbeds). Extract that trio into a shared helper in the package rather than copying it.internal/api/routes/communityFeed.go: registerGET /xrpc/social.coves.feed.searchPostswithOptionalAuth.- Rate limit: search is the most expensive read on the box. The route carries
a named
postSearchlimiter (30/min per client IP, created and mounted ininternal/api/routes/communityFeed.go) on top of the global 100/min cap. - Handler validation:
qrequired; a suppliedlimitthat is non-numeric or below 1 is a 400 (absentlimitdefaults to 15 in the service); the lexicon'sminimum: 1is the contract, so search is stricter thangetCommunity, which silently defaultslimit=0. - Caddy:
/xrpc/*is already in the@appviewallowlist. No Caddyfile change, andcaddy_allowlist_test.goskips/xrpc/routes.
1.6 Pre-existing drift to flag, not fix here #
Go serves /xrpc/social.coves.communityFeed.getCommunity and both clients
call that name, but the only lexicon is social.coves.feed.getCommunity
(internal/api/routes/communityFeed.go:26, coves-mobile/lib/services/coves_api_service.dart:243,
coves-frontend/src/lib/api/coves/client.ts:47). There is no
communityFeed.* lexicon. Register the new endpoint under its lexicon NSID
and file the getCommunity rename as its own issue (alias route, switch
clients, drop the old path a release later).
1.7 Tests #
T0 (no tag):
- Handler param parsing and error mapping (missing
q, bad limit, bad cursor). - Service validation with a fake repo (trim, empty, oversize
q; sort/limit defaults; community resolution failures map toCommunityNotFound). - Lexicon loads via the existing catalog test.
T1 (//go:build integration, in internal/db/postgres):
- Relevance: title match outranks body match; phrase and negation work;
stemming (
runningfindsrun). - Scoping: with
communityset, a matching post in another community is not returned; with it omitted, matches from several communities come back and the ranking is unaffected by which community a post is in. - Visibility: reuse the fixtures in
post_visibility_test.go. Pending, removed, rejected, pending_reacceptance, deleted, and wrong-CID posts are not returned to the public; the author sees their own pending post; a blocked author's post is filtered for the blocker. - Cursor round-trip for all three sorts with no duplicates or gaps across pages; tampered cursor rejected.
- Stopword-only query returns an empty feed and no cursor.
T2 (//go:build e2e, tests/e2e):
- One contract test: author writes a postv2 via the PDS, the community accepts
it,
feed.searchPostsfinds it by a title word; before acceptance the same query returns nothing. Followread_visibility_contract_test.go. Notime.Sleep(test-audit fails on it); poll with the suite's helpers.
1.8 Docs #
docs/COMMUNITY_FEEDS.md: add asearchPostssection besidegetCommunity.docs/PRD_POSTS.md: thecommunity.post.searchline now points atfeed.searchPosts; q, community filter, sorts, timeframe and the implementation items are ticked, author/type/tag filters stay open.docs/LEXICON_PUBLISHING.md(deploy-time, not done in the tree): the new NSID falls under the already-delegated_lexicon.feed.coves.social; publish it and unpublishsocial.coves.community.post.searchin the same sweep (see §4).
2. Mobile (coves-mobile, Flutter) — ~half a day #
Files: lib/screens/community/community_feed_screen.dart,
lib/services/coves_api_service.dart, lib/models/post.dart.
- API: add
searchCommunityPosts({community, q, sort='relevance', timeframe, limit=15, cursor})toCovesApiService. Because the response is{feed, cursor}it can go through the existing_getFeedhelper (add an optionalqparam) and parse withTimelineResponse.fromJsonunchanged. No codegen inlib/(hand-writtenfromJson), butCovesApiServiceis mocked, so rerundart run build_runner build --delete-conflicting-outputsto regeneratetest/test_helpers/test_mocks.mocks.dart. - Tabs:
_CommunityTabBar(L917-953) is a plainRowof two_TabItems driven by_selectedTabIndex; add a third (Icons.search, index 2). The body switch at L523-528 (_buildPostsList()vs_buildAboutSection()) becomes three-way. - Search header: the Feed tab's sort chips live in a pinned
SliverPersistentHeader(_FeedSortDelegate, 56px). Add a sibling delegate holding theTextField(style and clear button copied fromcommunities_discovery_screen.dartL489-540; 300msTimerdebounce as in L232-233 there). There is no shared debounced search widget; consider extracting one now since this is the third copy. - Pagination: a second
CursorPaginationController<FeedViewPost>whosefetchPagecalls the new API method with the current query.refresh()has a generation counter, so each debounced query change just callsrefresh()and stale responses are dropped. The screen has oneScrollControllerand_paginationListener.onLoadMoreis hardwired to_feedController.loadMore; dispatch on the active tab instead. - Results list:
PaginatedSliverList<FeedViewPost>exactly as_buildPostsList()(L668-724) withfooterKey: ValueKey('community_search_footer')(the widget asserts the_footersuffix) and an empty state cloned from_buildEmptyPostsState()("No results for …"). Empty query shows a prompt, not a request. - Identifier: pass
widget.identifierthrough asgetCommunityFeeddoes; the backend resolves DID or handle. Skip viewport-fill auto-paging while a query is active (precedent:communities_see_all_screen.dartL120-124). - Tests: one
DioAdaptercase intest/services/coves_api_service_test.dart(it matches the full query map, so list every param), and a first widget test forcommunity_feed_screen.dart(none exists today; needsAuthProvider,CommunitySubscriptionProvider,VoteProvider,CovesApiServicevia Provider, seecommunities_see_all_screen_test.dart). The theme guard tests reject raw colour literals; useAppColors.
3. Frontend (coves-frontend, SvelteKit) — ~half a day #
Files: src/lib/api/coves/client.ts, src/lib/api/coves/types.ts,
src/routes/c/[handle=handle]/+page.ts and +page.svelte,
src/lib/feature/feeds/feed.svelte.ts.
- Client: add
searchPosts: 'social.coves.feed.searchPosts'to theNSIDmap and a one-linesearchPosts(params)method. Types:SearchPostsParamsnext toSearchCommunitiesParams(types.ts L578); response is the existingFeedResponse, soPostListShell,PostFeed,VirtualFeed, andloadFeedneed no mapping. Types are hand-written, no codegen. - Route: keep search on the community route as
?q=. In+page.ts, whenqis present callsearchPostsinstead ofgetCommunityFeedand default sort torelevance; threadqinto both the initial load andloadFeed. The feed cache is keyed by route id and invalidates onrecursiveEqual(params), soqmust be part of the'/c/[handle=handle]'params inFeedTypesor a new query reuses the old results. - UI: Photon's
/searchroute and the per-community search box were removed in2fd3fa52; nothing to revive. Put aSearchBar(src/lib/ui/layout/SearchBar.svelte, input nameq) in the page'sextendedsnippet underCommunityHeader, as a GET form so it works without JS, following the URL-as-source-of-truth pattern insrc/routes/explore/+layout.svelteL20-57. i18n keysroutes.search.*already exist in every locale. - Sort menu:
CovesSortTypeishot|new|top. Whileqis set, showrelevance|new|top(or hide the menu and pin relevance). Decide in the PR; either is small. - Tests (vitest, no Playwright): a
searchPostscase insrc/lib/api/coves/client.test.tsbesidegetCommunityFeed, and a loader case insrc/routes/c/[handle=handle]/page.test.tsasserting thatqswitches the call and that a changedqbusts the feed cache.
4. Order of work #
- Backend PR: lexicon, migration, repo, service, handler, route, limiter,
T0/T1/T2 tests, docs. Gate with
make ci. Done (branchtdd/post-search). - Deploy backend (
/deploy). Migration rewritesposts; expect a brief lock. Then the lexicon sweep perdocs/LEXICON_PUBLISHING.md: publishsocial.coves.feed.searchPosts, unpublishsocial.coves.community.post.search. - Mobile and frontend PRs in parallel against the deployed endpoint.
- Follow-ups, separately: getCommunity NSID drift (§1.6); author/tag/type
filters from
docs/PRD_POSTS.md; a Home search surface that fans out to this pluscommunity.search.
5. Read-path gaps found while scoping (not caused by search, inherited by it) #
communities.visibility(public|unlisted|private) is never consulted on any read path. Discover, the community feed, the timeline and therefore search all serve posts from private communities to anyone. Still open; tracked in the central issues backlog (2026-09-01-community-visibility-never-consulted-on-read-paths).community_blocks: resolved on main (70c36f5, 2026-09-01) as an aggregate-feed mute viaviewerBlockFiltersininternal/db/postgres/viewer_block_filter.go. Discover, the timeline and cross-community search hide blocked communities; a request scoped to one community still shows it. Search adopted the helper on 2026-09-02 (TestSearchPosts_CrossCommunitySearchHonoursCommunityBlocks).