[READ ONLY MIRROR] Spark Social AppView Server
github.com/sprksocial/server
atproto
deno
hono
lexicon
1import { Database } from "../db/index.ts";
2import { Code, DataPlaneError } from "../util.ts";
3
4// Parameter validation
5function validateThreadParams(above: number, below: number) {
6 if (!Number.isInteger(above) || above < 0 || above > 100) {
7 throw new Error("Invalid above: must be an integer between 0 and 100");
8 }
9
10 if (!Number.isInteger(below) || below < 0 || below > 100) {
11 throw new Error("Invalid below: must be an integer between 0 and 100");
12 }
13}
14
15// Helper function to get descendants (child replies going down the thread)
16async function getDescendants(
17 db: Database,
18 parentUri: string,
19 maxDepth: number,
20): Promise<string[]> {
21 const descendants: string[] = [];
22 const visited = new Set<string>();
23
24 // Use BFS to traverse descendants
25 const queue: Array<{ uri: string; depth: number }> = [{
26 uri: parentUri,
27 depth: 0,
28 }];
29
30 while (queue.length > 0) {
31 const { uri: currentUri, depth } = queue.shift()!;
32
33 if (depth >= maxDepth || visited.has(currentUri)) {
34 continue;
35 }
36
37 visited.add(currentUri);
38
39 // Find all replies to this post/reply
40 const replies = await db.models.Reply.find({
41 "reply.parent.uri": currentUri,
42 })
43 .sort({ createdAt: -1 }); // Most recent first
44
45 for (const reply of replies) {
46 if (!visited.has(reply.uri)) {
47 descendants.push(reply.uri);
48
49 // Add to queue for further traversal if we haven't reached max depth
50 if (depth + 1 < maxDepth) {
51 queue.push({ uri: reply.uri, depth: depth + 1 });
52 }
53 }
54 }
55 }
56
57 return descendants;
58}
59
60export class Threads {
61 private db: Database;
62
63 constructor(db: Database) {
64 this.db = db;
65 }
66
67 async getThread(postUri: string, above: number = 10, below: number = 50) {
68 validateThreadParams(above, below);
69
70 try {
71 // Check if it's a post or reply
72 const originalPost = await this.db.models.Post.findOne({ uri: postUri });
73
74 if (originalPost) {
75 // Posts are always root - they don't have ancestors by design
76 // So we only get descendants (replies)
77 const descendants = await getDescendants(this.db, postUri, below);
78
79 // The thread is just the root post + all its descendant replies
80 const uris = [
81 postUri, // The original post (always root)
82 ...descendants,
83 ];
84
85 // Remove duplicates while preserving order
86 const uniqueUris = Array.from(new Set(uris));
87
88 return {
89 uris: uniqueUris,
90 meta: {
91 ancestorCount: 0, // Posts never have ancestors
92 descendantCount: descendants.length,
93 totalCount: uniqueUris.length,
94 },
95 };
96 }
97
98 // Check if it's a reply
99 const originalReply = await this.db.models.Reply.findOne({
100 uri: postUri,
101 });
102
103 if (!originalReply) {
104 throw new DataPlaneError(Code.NotFound);
105 }
106
107 // Get ancestors (walking up the reply chain)
108 const ancestors: string[] = [];
109 let currentUri = postUri;
110 const visited = new Set<string>([currentUri]);
111
112 for (let i = 0; i < above; i++) {
113 const current = await this.db.models.Reply.findOne({ uri: currentUri });
114
115 if (!current?.reply?.parent?.uri) {
116 break;
117 }
118
119 const parentUri = current.reply.parent.uri;
120
121 if (visited.has(parentUri)) {
122 break;
123 }
124
125 visited.add(parentUri);
126 ancestors.unshift(parentUri); // Add to beginning to maintain order
127 currentUri = parentUri;
128 }
129
130 // Get descendants (replies to this reply)
131 const descendants = await getDescendants(this.db, postUri, below);
132
133 // Build the full thread: ancestors + anchor + descendants
134 const uris = [
135 ...ancestors,
136 postUri, // The anchor reply
137 ...descendants,
138 ];
139
140 // Remove duplicates while preserving order
141 const uniqueUris = Array.from(new Set(uris));
142
143 return {
144 uris: uniqueUris,
145 meta: {
146 ancestorCount: ancestors.length,
147 descendantCount: descendants.length,
148 totalCount: uniqueUris.length,
149 },
150 };
151 } catch (error) {
152 console.error("Error fetching thread:", error);
153 throw new DataPlaneError(Code.InternalError);
154 }
155 }
156
157 async getThreadStructure(
158 postUri: string,
159 above: number = 10,
160 below: number = 50,
161 ) {
162 validateThreadParams(above, below);
163
164 try {
165 // Get the original post
166 const originalPost = await this.db.models.Post.findOne({ uri: postUri });
167
168 if (!originalPost) {
169 throw new DataPlaneError(Code.NotFound);
170 }
171
172 // Posts don't have ancestors - they are always roots
173 const ancestors: Array<{ uri: string; depth: number }> = [];
174
175 // Get descendants with metadata using BFS
176 const descendants: Array<
177 { uri: string; depth: number; parent: string }
178 > = [];
179 const queue: Array<{ uri: string; depth: number; parent: string }> = [
180 { uri: postUri, depth: 0, parent: postUri },
181 ];
182 const visited = new Set<string>([postUri]);
183
184 while (queue.length > 0) {
185 const { uri: currentUri, depth: currentDepth } = queue.shift()!;
186
187 if (currentDepth >= below) {
188 continue;
189 }
190
191 // Find replies to this post/reply
192 const replies = await this.db.models.Reply.find({
193 "reply.parent.uri": currentUri,
194 })
195 .sort({ createdAt: -1 });
196
197 for (const reply of replies) {
198 if (!visited.has(reply.uri)) {
199 visited.add(reply.uri);
200 const childDepth = currentDepth + 1;
201
202 descendants.push({
203 uri: reply.uri,
204 depth: childDepth,
205 parent: currentUri,
206 });
207
208 if (childDepth < below) {
209 queue.push({
210 uri: reply.uri,
211 depth: childDepth,
212 parent: reply.uri,
213 });
214 }
215 }
216 }
217 }
218
219 return {
220 root: {
221 uri: postUri,
222 isRoot: true, // Posts are always roots
223 },
224 ancestors, // Always empty for posts
225 descendants,
226 meta: {
227 ancestorCount: 0, // Posts never have ancestors
228 descendantCount: descendants.length,
229 maxAncestorDepth: 0, // Posts never have ancestors
230 maxDescendantDepth: descendants.length > 0
231 ? Math.max(...descendants.map((d) => d.depth))
232 : 0,
233 },
234 };
235 } catch (error) {
236 console.error("Error fetching thread structure:", error);
237 throw new DataPlaneError(Code.InternalError);
238 }
239 }
240
241 // New method: Get thread starting from a reply (if needed for UI purposes)
242 // This would find the root post and then build the full thread
243 async getThreadFromReply(replyUri: string, below: number = 50) {
244 validateThreadParams(0, below); // No ancestors needed
245
246 try {
247 // Find the reply
248 const reply = await this.db.models.Reply.findOne({ uri: replyUri });
249
250 if (!reply) {
251 throw new DataPlaneError(Code.NotFound);
252 }
253
254 // Walk up to find the root post
255 let currentUri = replyUri;
256 let rootUri: string | null = null;
257
258 // Keep going up until we find a post (not a reply)
259 while (rootUri === null) {
260 const currentReply = await this.db.models.Reply.findOne({
261 uri: currentUri,
262 });
263
264 if (!currentReply || !currentReply.reply?.parent?.uri) {
265 // This shouldn't happen if data integrity is maintained
266 throw new DataPlaneError(Code.NotFound);
267 }
268
269 const parentUri = currentReply.reply.parent.uri;
270
271 // Check if parent is a post (root) or another reply
272 const parentPost = await this.db.models.Post.findOne({
273 uri: parentUri,
274 });
275
276 if (parentPost) {
277 rootUri = parentUri;
278 } else {
279 // Parent is another reply, keep going up
280 currentUri = parentUri;
281 }
282 }
283
284 // Now get the full thread starting from the root post
285 return this.getThread(rootUri, 0, below);
286 } catch (error) {
287 console.error("Error fetching thread from reply:", error);
288 throw new Error("Failed to fetch thread from reply");
289 }
290 }
291}