From 23782e76e2b0d46815367da5a2e840b7af323801 Mon Sep 17 00:00:00 2001 From: Roscoe Rubin-Rottenberg Date: Wed, 5 Aug 2026 12:26:39 -0400 Subject: [PATCH] fix: handle domains in search --- data-plane/routes/search.ts | 24 ++++-- tests/search_test.ts | 149 ++++++++++++++++++++++++++++++++++++ tests/util.ts | 17 ++++ 3 files changed, 185 insertions(+), 5 deletions(-) create mode 100644 tests/search_test.ts diff --git a/data-plane/routes/search.ts b/data-plane/routes/search.ts index 69e4464..ae69144 100644 --- a/data-plane/routes/search.ts +++ b/data-plane/routes/search.ts @@ -105,6 +105,7 @@ export class Search { const candidateLimit = safeLimit * 3; const handlePrefix = cleanedTerm.toLowerCase(); const handleRangeEnd = `${handlePrefix}\uffff`; + const handleOnly = cleanedTerm.includes(".") && !/\s/.test(cleanedTerm); const matchingActors = await this.db.models.Actor.find({ handle: { @@ -112,13 +113,18 @@ export class Search { $lt: handleRangeEnd, }, }) - .select("did -_id") + .select("did handle -_id") .sort({ handle: 1 }) .limit(candidateLimit) .lean(); const handleDids = matchingActors.map((actor) => actor.did); - const profileQuery = handleDids.length > 0 + const exactHandleDid = matchingActors.find((actor) => + actor.handle === handlePrefix + )?.did; + const profileQuery = handleOnly + ? { authorDid: { $in: handleDids } } + : handleDids.length > 0 ? { $or: [ { authorDid: { $in: handleDids } }, @@ -139,8 +145,12 @@ export class Search { const handleProfileDidSet = new Set( matchingProfiles.map((p) => p.authorDid), ); + const exactHandleProfileDid = exactHandleDid && + handleProfileDidSet.has(exactHandleDid) + ? exactHandleDid + : undefined; const handleProfileDids = handleDids.filter((did) => - handleProfileDidSet.has(did) + did !== exactHandleProfileDid && handleProfileDidSet.has(did) ); const includedDids = new Set(handleProfileDids); const textProfileDids = matchingProfiles @@ -153,9 +163,11 @@ export class Search { handleProfileDids.sort(byFollowers); textProfileDids.sort(byFollowers); + const candidatePoolLimit = safeLimit * 2 - + (exactHandleProfileDid ? 1 : 0); let candidates = [...handleProfileDids, ...textProfileDids].slice( 0, - safeLimit * 2, + candidatePoolLimit, ); // Boost accounts the viewer already follows to the front @@ -172,7 +184,9 @@ export class Search { } return { - dids: candidates.slice(0, safeLimit), + dids: exactHandleProfileDid + ? [exactHandleProfileDid, ...candidates].slice(0, safeLimit) + : candidates.slice(0, safeLimit), }; } diff --git a/tests/search_test.ts b/tests/search_test.ts new file mode 100644 index 0000000..96f2f72 --- /dev/null +++ b/tests/search_test.ts @@ -0,0 +1,149 @@ +import { assertEquals } from "@std/assert"; + +import { createTestContext, EMPTY_TEST_DATA } from "./util.ts"; + +Deno.test({ + name: "Actor typeahead", + sanitizeOps: false, + sanitizeResources: false, + fn: async (t) => { + const { ctx, cleanup } = await createTestContext(EMPTY_TEST_DATA); + + try { + await ctx.db.models.Actor.init(); + await ctx.db.models.Profile.init(); + + const now = new Date().toISOString(); + const exactDid = "did:plc:exacthandle"; + const longerHandleDid = "did:plc:longerhandle"; + const secondLongerHandleDid = "did:plc:secondlongerhandle"; + const textMatchDid = "did:plc:textmatch"; + const viewerDid = "did:plc:viewer"; + + await ctx.db.models.Actor.create([ + { + did: exactDid, + handle: "alice.test", + indexedAt: now, + keys: [], + services: "[]", + }, + { + did: longerHandleDid, + handle: "alice.testing", + indexedAt: now, + keys: [], + services: "[]", + }, + { + did: secondLongerHandleDid, + handle: "alice.testers", + indexedAt: now, + keys: [], + services: "[]", + }, + { + did: textMatchDid, + handle: "unrelated.social", + indexedAt: now, + keys: [], + services: "[]", + }, + ]); + await ctx.db.models.Profile.create([ + { + uri: `at://${exactDid}/app.bsky.actor.profile/self`, + cid: "bafyreiexactprofile", + authorDid: exactDid, + createdAt: now, + indexedAt: now, + displayName: "Alice", + followersCount: 1, + }, + { + uri: `at://${longerHandleDid}/app.bsky.actor.profile/self`, + cid: "bafyreilongerprofile", + authorDid: longerHandleDid, + createdAt: now, + indexedAt: now, + displayName: "Popular Alice", + followersCount: 100, + }, + { + uri: `at://${secondLongerHandleDid}/app.bsky.actor.profile/self`, + cid: "bafyreisecondlongerprofile", + authorDid: secondLongerHandleDid, + createdAt: now, + indexedAt: now, + displayName: "More Popular Alice", + followersCount: 200, + }, + { + uri: `at://${textMatchDid}/app.bsky.actor.profile/self`, + cid: "bafyreitextprofile", + authorDid: textMatchDid, + createdAt: now, + indexedAt: now, + displayName: "Test Example", + }, + ]); + await ctx.db.models.Follow.create({ + uri: `at://${viewerDid}/app.bsky.graph.follow/longer-handle`, + cid: "bafyreifollowlongerhandle", + authorDid: viewerDid, + createdAt: now, + indexedAt: now, + subject: longerHandleDid, + }); + + await t.step("uses profile text search for names", async () => { + const result = await ctx.dataplane.search.actorsTypeahead("example"); + + assertEquals(result.dids, [textMatchDid]); + }); + + await t.step( + "does not broaden dotted handles into text terms", + async () => { + const result = await ctx.dataplane.search.actorsTypeahead( + "alice.test", + ); + + assertEquals(result.dids, [ + exactDid, + secondLongerHandleDid, + longerHandleDid, + ]); + }, + ); + + await t.step("keeps the exact handle with a small limit", async () => { + const result = await ctx.dataplane.search.actorsTypeahead( + "alice.test", + 1, + ); + + assertEquals(result.dids, [exactDid]); + }); + + await t.step( + "keeps the exact handle ahead of followed candidates", + async () => { + const result = await ctx.dataplane.search.actorsTypeahead( + "alice.test", + 3, + viewerDid, + ); + + assertEquals(result.dids, [ + exactDid, + longerHandleDid, + secondLongerHandleDid, + ]); + }, + ); + } finally { + await cleanup(); + } + }, +}); diff --git a/tests/util.ts b/tests/util.ts index 61022f5..757fe3c 100644 --- a/tests/util.ts +++ b/tests/util.ts @@ -41,6 +41,23 @@ export interface TestDataOptions { actorSync?: boolean; } +export const EMPTY_TEST_DATA: Required = { + actors: false, + profiles: false, + posts: false, + replies: false, + stories: false, + likes: false, + reposts: false, + follows: false, + blocks: false, + audio: false, + generators: false, + preferences: false, + records: false, + actorSync: false, +}; + // ============================================================================ // Test Database Types // ============================================================================ -- 2.51.2