[READ ONLY MIRROR] Spark Social AppView Server
github.com/sprksocial/server
atproto
deno
hono
lexicon
1import { Database } from "../db/index.ts";
2import { IndexedAtDidKeyset, TimeCidKeyset } from "../db/pagination.ts";
3import { parsePostSearchQuery } from "../util.ts";
4import { compositeTime } from "../util.ts";
5
6// Remove leading @ in case a handle is input that way
7const cleanQuery = (query: string) => query.trim().replace(/^@/g, "");
8
9export class Search {
10 private db: Database;
11 private indexedAtDidKeyset: IndexedAtDidKeyset;
12 private timeCidKeyset: TimeCidKeyset;
13
14 constructor(db: Database) {
15 this.db = db;
16 this.indexedAtDidKeyset = new IndexedAtDidKeyset();
17 this.timeCidKeyset = new TimeCidKeyset();
18 }
19
20 async actors(term: string, limit = 50, cursor?: string) {
21 const cleanedTerm = cleanQuery(term);
22 if (!cleanedTerm) {
23 return {
24 dids: [],
25 cursor: undefined,
26 };
27 }
28
29 const handlePrefix = cleanedTerm.toLowerCase();
30 const handleRangeEnd = `${handlePrefix}\uffff`;
31
32 const [matchingActors, matchingProfiles] = await Promise.all([
33 this.db.models.Actor.find({
34 handle: {
35 $gte: handlePrefix,
36 $lt: handleRangeEnd,
37 },
38 }).select("did -_id").lean(),
39 this.db.models.Profile.find({
40 $text: { $search: cleanedTerm },
41 }).select("authorDid -_id").lean(),
42 ]);
43
44 const matchingActorDids = matchingActors.map((actor) => actor.did);
45 const matchingProfileDids = matchingProfiles.map((profile) =>
46 profile.authorDid
47 );
48 const dids = Array.from(
49 new Set([...matchingActorDids, ...matchingProfileDids]),
50 );
51
52 if (dids.length === 0) {
53 return {
54 dids: [],
55 cursor: undefined,
56 };
57 }
58
59 const profilesQuery = this.db.models.Profile.find({
60 authorDid: { $in: dids },
61 }).select("authorDid indexedAt -_id");
62
63 const paginatedQuery = this.indexedAtDidKeyset.paginate(
64 profilesQuery,
65 {
66 limit: limit + 1, // Fetch one extra to check if more results exist
67 cursor,
68 direction: "desc",
69 },
70 );
71
72 const profiles = await paginatedQuery.exec();
73
74 // Check if there are more results
75 const hasMore = profiles.length > limit;
76 const results = hasMore ? profiles.slice(0, limit) : profiles;
77
78 // Generate cursor from the last item if we have more results
79 let nextCursor: string | undefined;
80 if (hasMore && results.length > 0) {
81 const lastProfile = results[results.length - 1];
82 nextCursor = this.indexedAtDidKeyset.pack({
83 primary: lastProfile.indexedAt,
84 secondary: lastProfile.authorDid,
85 });
86 }
87
88 return {
89 dids: results.map((profile: { authorDid: string; indexedAt: string }) =>
90 profile.authorDid
91 ),
92 cursor: nextCursor,
93 };
94 }
95
96 async actorsTypeahead(term: string, limit = 10, viewerDid?: string | null) {
97 const cleanedTerm = cleanQuery(term);
98 if (!cleanedTerm) {
99 return {
100 dids: [],
101 };
102 }
103
104 const safeLimit = Math.max(1, Math.min(limit, 100));
105 const candidateLimit = safeLimit * 3;
106 const handlePrefix = cleanedTerm.toLowerCase();
107 const handleRangeEnd = `${handlePrefix}\uffff`;
108
109 const matchingActors = await this.db.models.Actor.find({
110 handle: {
111 $gte: handlePrefix,
112 $lt: handleRangeEnd,
113 },
114 })
115 .select("did -_id")
116 .sort({ handle: 1 })
117 .limit(candidateLimit)
118 .lean();
119
120 const handleDids = matchingActors.map((actor) => actor.did);
121 const profileQuery = handleDids.length > 0
122 ? {
123 $or: [
124 { authorDid: { $in: handleDids } },
125 { $text: { $search: cleanedTerm } },
126 ],
127 }
128 : { $text: { $search: cleanedTerm } };
129 const matchingProfiles = await this.db.models.Profile.find(profileQuery)
130 .select("authorDid followersCount -_id")
131 .limit(candidateLimit * 2)
132 .lean();
133
134 const followerCountMap = new Map<string, number>(
135 matchingProfiles.map((p) => [p.authorDid, p.followersCount ?? 0]),
136 );
137
138 const handleDidSet = new Set(handleDids);
139 const handleProfileDidSet = new Set(
140 matchingProfiles.map((p) => p.authorDid),
141 );
142 const handleProfileDids = handleDids.filter((did) =>
143 handleProfileDidSet.has(did)
144 );
145 const includedDids = new Set(handleProfileDids);
146 const textProfileDids = matchingProfiles
147 .map((profile) => profile.authorDid)
148 .filter((did) => !includedDids.has(did) && !handleDidSet.has(did));
149
150 // Sort each group by follower count descending
151 const byFollowers = (a: string, b: string) =>
152 (followerCountMap.get(b) ?? 0) - (followerCountMap.get(a) ?? 0);
153 handleProfileDids.sort(byFollowers);
154 textProfileDids.sort(byFollowers);
155
156 let candidates = [...handleProfileDids, ...textProfileDids].slice(
157 0,
158 safeLimit * 2,
159 );
160
161 // Boost accounts the viewer already follows to the front
162 if (viewerDid && candidates.length > 0) {
163 const viewerFollows = await this.db.models.Follow.find({
164 authorDid: viewerDid,
165 subject: { $in: candidates },
166 }).select("subject -_id").lean();
167 const followedSet = new Set(viewerFollows.map((f) => f.subject));
168 candidates = [
169 ...candidates.filter((did) => followedSet.has(did)),
170 ...candidates.filter((did) => !followedSet.has(did)),
171 ];
172 }
173
174 return {
175 dids: candidates.slice(0, safeLimit),
176 };
177 }
178
179 async posts(term: string, limit = 50, cursor?: string) {
180 const { q, author } = parsePostSearchQuery(term);
181
182 let authorDid = author;
183 if (author && !author?.startsWith("did:")) {
184 const actor = await this.db.models.Actor.findOne({
185 handle: author,
186 });
187 authorDid = actor?.did;
188 }
189
190 // Build query for posts matching the search term
191 const query: Record<string, unknown> = {};
192
193 if (q) {
194 // Search in multiple fields for better relevance
195 query.$or = [
196 { "caption.text": { $regex: q, $options: "i" } },
197 { "media.images.alt": { $regex: q, $options: "i" } },
198 { "media.video.alt": { $regex: q, $options: "i" } },
199 { tags: { $regex: q, $options: "i" } },
200 ];
201 }
202
203 if (authorDid) {
204 query.authorDid = authorDid;
205 }
206
207 const postsQuery = this.db.models.Post.find(query);
208
209 // Apply pagination using createdAt + cid (which matches DB schema and indexes)
210 const paginatedQuery = this.timeCidKeyset.paginate(postsQuery, {
211 limit,
212 cursor,
213 direction: "desc",
214 });
215
216 const posts = await paginatedQuery.exec();
217
218 // Transform posts to include sortAt for cursor generation
219 const transformedPosts = posts.map((p) => ({
220 uri: p.uri,
221 cid: p.cid,
222 sortAt: compositeTime(p.createdAt, p.indexedAt) || p.createdAt,
223 }));
224
225 // Generate cursor from the last item if we have a full page
226 let nextCursor: string | undefined;
227 if (transformedPosts.length === limit && transformedPosts.length > 0) {
228 const lastPost = transformedPosts[transformedPosts.length - 1];
229 nextCursor = this.timeCidKeyset.pack({
230 primary: lastPost.sortAt,
231 secondary: lastPost.cid,
232 });
233 }
234
235 return {
236 uris: transformedPosts.map((p) => p.uri),
237 cursor: nextCursor,
238 };
239 }
240}