[READ ONLY MIRROR] Spark Social AppView Server
github.com/sprksocial/server
atproto
deno
hono
lexicon
1import { ensureValidRecordKey } from "@atp/syntax";
2import { InvalidRequestError } from "@atp/xrpc-server";
3import { Document, Query, QueryFilter } from "mongoose";
4
5type KeysetCursor = { primary: string; secondary: string };
6type KeysetLabeledResult = {
7 primary: string | number;
8 secondary: string | number;
9};
10
11/**
12 * The GenericKeyset is an abstract class that sets-up the interface and partial implementation
13 * of a keyset-paginated cursor with two parts. There are three types involved:
14 * - Result: a raw result (i.e. a document from the db) containing data that will make-up a cursor.
15 * - E.g. { createdAt: '2022-01-01T12:00:00Z', cid: 'bafyx' }
16 * - LabeledResult: a Result processed such that the "primary" and "secondary" parts of the cursor are labeled.
17 * - E.g. { primary: '2022-01-01T12:00:00Z', secondary: 'bafyx' }
18 * - Cursor: the two string parts that make-up the packed/string cursor.
19 * - E.g. packed cursor '1641038400000__bafyx' in parts { primary: '1641038400000', secondary: 'bafyx' }
20 *
21 * These types relate as such. Implementers define the relations marked with a *:
22 * Result -*-> LabeledResult <-*-> Cursor <--> packed/string cursor
23 * ↳ MongoDB Filter Condition
24 */
25export abstract class GenericKeyset<R, LR extends KeysetLabeledResult> {
26 constructor(
27 public primary: string,
28 public secondary: string,
29 ) {}
30 abstract labelResult(result: R): LR;
31 abstract labeledResultToCursor(labeled: LR): KeysetCursor;
32 abstract cursorToLabeledResult(cursor: KeysetCursor): LR;
33 packFromResult(results: R | R[]): string | undefined {
34 const result = Array.isArray(results) ? results.at(-1) : results;
35 if (!result) return;
36 return this.pack(this.labelResult(result));
37 }
38 pack(labeled?: LR): string | undefined {
39 if (!labeled) return;
40 const cursor = this.labeledResultToCursor(labeled);
41 return this.packCursor(cursor);
42 }
43 unpack(cursorStr?: string): LR | undefined {
44 const cursor = this.unpackCursor(cursorStr);
45 if (!cursor) return;
46 return this.cursorToLabeledResult(cursor);
47 }
48 packCursor(cursor?: KeysetCursor): string | undefined {
49 if (!cursor) return;
50 // Use colon as separator (more compact than double underscore)
51 return `${cursor.primary}:${cursor.secondary}`;
52 }
53 unpackCursor(cursorStr?: string): KeysetCursor | undefined {
54 if (!cursorStr) return;
55 const separatorIndex = cursorStr.indexOf(":");
56 if (separatorIndex === -1) {
57 throw new InvalidRequestError("Malformed cursor: missing separator");
58 }
59 const primary = cursorStr.slice(0, separatorIndex);
60 const secondary = cursorStr.slice(separatorIndex + 1);
61 if (!primary || !secondary) {
62 throw new InvalidRequestError(
63 "Malformed cursor: missing primary or secondary",
64 );
65 }
66 return {
67 primary,
68 secondary,
69 };
70 }
71 getFilter<T>(
72 labeled?: LR,
73 direction?: "asc" | "desc",
74 ): QueryFilter<T> | undefined {
75 if (labeled === undefined) return undefined;
76
77 // MongoDB compound key comparison using $or
78 if (direction === "asc") {
79 return {
80 $or: [
81 { [this.primary]: { $gt: labeled.primary } },
82 {
83 [this.primary]: labeled.primary,
84 [this.secondary]: { $gt: labeled.secondary },
85 },
86 ],
87 } as QueryFilter<T>;
88 } else {
89 return {
90 $or: [
91 { [this.primary]: { $lt: labeled.primary } },
92 {
93 [this.primary]: labeled.primary,
94 [this.secondary]: { $lt: labeled.secondary },
95 },
96 ],
97 } as QueryFilter<T>;
98 }
99 }
100 paginate<T extends Document>(
101 query: Query<T[], T>,
102 opts: {
103 limit?: number;
104 cursor?: string;
105 direction?: "asc" | "desc";
106 },
107 ): Query<T[], T> {
108 const { limit, cursor, direction = "desc" } = opts;
109 const keysetFilter = this.getFilter<T>(this.unpack(cursor), direction);
110
111 if (keysetFilter) {
112 query = query.where(keysetFilter);
113 }
114
115 if (limit) {
116 query = query.limit(limit);
117 }
118
119 // Set up sorting
120 const sortOrder = direction === "asc" ? 1 : -1;
121 query = query.sort({
122 [this.primary]: sortOrder,
123 [this.secondary]: sortOrder,
124 });
125
126 return query;
127 }
128}
129
130type IndexedAtCidResult = { indexedAt?: string; cid: string };
131type TimeCidLabeledResult = KeysetCursor;
132
133export class TimeCidKeyset<
134 TimeCidResult = IndexedAtCidResult,
135> extends GenericKeyset<TimeCidResult, TimeCidLabeledResult> {
136 constructor() {
137 super("indexedAt", "cid");
138 }
139
140 labelResult(result: TimeCidResult): TimeCidLabeledResult;
141 labelResult<TimeCidResult extends IndexedAtCidResult>(result: TimeCidResult) {
142 // Use current time as fallback if indexedAt is missing
143 const indexedAt = result.indexedAt || new Date().toISOString();
144 return { primary: indexedAt, secondary: result.cid };
145 }
146 labeledResultToCursor(labeled: TimeCidLabeledResult) {
147 const timestamp = new Date(labeled.primary).getTime();
148 if (isNaN(timestamp)) {
149 throw new InvalidRequestError("Invalid date for cursor");
150 }
151 // Use seconds instead of milliseconds and base36 encoding for compactness
152 const secondsBase36 = Math.floor(timestamp / 1000).toString(36);
153 return {
154 primary: secondsBase36,
155 secondary: labeled.secondary,
156 };
157 }
158 cursorToLabeledResult(cursor: KeysetCursor) {
159 // Parse from base36 and convert seconds back to milliseconds
160 const seconds = parseInt(cursor.primary, 36);
161 if (isNaN(seconds)) {
162 throw new InvalidRequestError("Malformed cursor: invalid timestamp");
163 }
164 const primaryDate = new Date(seconds * 1000);
165 if (isNaN(primaryDate.getTime())) {
166 throw new InvalidRequestError("Malformed cursor: invalid date");
167 }
168 return {
169 primary: primaryDate.toISOString(),
170 secondary: cursor.secondary,
171 };
172 }
173}
174
175export class CreatedAtDidKeyset extends TimeCidKeyset<{
176 createdAt: string;
177 authorDid: string; // dids are treated identically to cids in TimeCidKeyset
178}> {
179 constructor() {
180 super();
181 this.primary = "createdAt";
182 this.secondary = "authorDid";
183 }
184
185 override labelResult(result: { createdAt: string; authorDid: string }) {
186 // Use current time as fallback if createdAt is missing
187 const createdAt = result.createdAt || new Date().toISOString();
188 return { primary: createdAt, secondary: result.authorDid };
189 }
190}
191
192export class IndexedAtDidKeyset extends TimeCidKeyset<{
193 indexedAt: string;
194 authorDid: string; // dids are treated identically to cids in TimeCidKeyset
195}> {
196 constructor() {
197 super();
198 this.primary = "indexedAt";
199 this.secondary = "authorDid";
200 }
201
202 override labelResult(result: { indexedAt: string; authorDid: string }) {
203 // Use current time as fallback if indexedAt is missing
204 const indexedAt = result.indexedAt || new Date().toISOString();
205 return { primary: indexedAt, secondary: result.authorDid };
206 }
207}
208
209/**
210 * This is being deprecated. Use {@link GenericKeyset#paginate} instead.
211 */
212export const paginate = <
213 T extends Document,
214 K extends GenericKeyset<unknown, KeysetLabeledResult>,
215>(
216 query: Query<T[], T>,
217 opts: {
218 limit?: number;
219 cursor?: string;
220 direction?: "asc" | "desc";
221 keyset: K;
222 },
223): Query<T[], T> => {
224 return opts.keyset.paginate(query, opts);
225};
226
227type SingleKeyCursor = {
228 primary: string;
229};
230
231type SingleKeyLabeledResult = {
232 primary: string | number;
233};
234
235/**
236 * GenericSingleKey is similar to {@link GenericKeyset} but for a single key cursor.
237 */
238export abstract class GenericSingleKey<R, LR extends SingleKeyLabeledResult> {
239 constructor(public primary: string) {}
240 abstract labelResult(result: R): LR;
241 abstract labeledResultToCursor(labeled: LR): SingleKeyCursor;
242 abstract cursorToLabeledResult(cursor: SingleKeyCursor): LR;
243 packFromResult(results: R | R[]): string | undefined {
244 const result = Array.isArray(results) ? results.at(-1) : results;
245 if (!result) return;
246 return this.pack(this.labelResult(result));
247 }
248 pack(labeled?: LR): string | undefined {
249 if (!labeled) return;
250 const cursor = this.labeledResultToCursor(labeled);
251 return this.packCursor(cursor);
252 }
253 unpack(cursorStr?: string): LR | undefined {
254 const cursor = this.unpackCursor(cursorStr);
255 if (!cursor) return;
256 return this.cursorToLabeledResult(cursor);
257 }
258 packCursor(cursor?: SingleKeyCursor): string | undefined {
259 if (!cursor) return;
260 return cursor.primary;
261 }
262 unpackCursor(cursorStr?: string): SingleKeyCursor | undefined {
263 if (!cursorStr) return;
264 // Single key cursors don't use separators
265 if (cursorStr.includes(":") || cursorStr.includes("__")) {
266 throw new InvalidRequestError(
267 "Malformed cursor: unexpected separator in single key cursor",
268 );
269 }
270 return {
271 primary: cursorStr,
272 };
273 }
274 getFilter<T>(
275 labeled?: LR,
276 direction?: "asc" | "desc",
277 ): QueryFilter<T> | undefined {
278 if (labeled === undefined) return undefined;
279 if (direction === "asc") {
280 return { [this.primary]: { $gt: labeled.primary } } as QueryFilter<T>;
281 }
282 return { [this.primary]: { $lt: labeled.primary } } as QueryFilter<T>;
283 }
284 paginate<T extends Document>(
285 query: Query<T[], T>,
286 opts: {
287 limit?: number;
288 cursor?: string;
289 direction?: "asc" | "desc";
290 },
291 ): Query<T[], T> {
292 const { limit, cursor, direction = "desc" } = opts;
293 const keyFilter = this.getFilter<T>(this.unpack(cursor), direction);
294
295 if (keyFilter) {
296 query = query.where(keyFilter);
297 }
298
299 if (limit) {
300 query = query.limit(limit);
301 }
302
303 const sortOrder = direction === "asc" ? 1 : -1;
304 query = query.sort({ [this.primary]: sortOrder });
305
306 return query;
307 }
308}
309
310type IndexedAtResult = { indexedAt: string };
311type TimeLabeledResult = SingleKeyCursor;
312
313export class IsoTimeKey<TimeResult = IndexedAtResult> extends GenericSingleKey<
314 TimeResult,
315 TimeLabeledResult
316> {
317 constructor() {
318 super("indexedAt");
319 }
320
321 labelResult(result: TimeResult): TimeLabeledResult;
322 labelResult<TimeResult extends IndexedAtResult>(result: TimeResult) {
323 return { primary: result.indexedAt };
324 }
325 labeledResultToCursor(labeled: TimeLabeledResult) {
326 const primaryDate = new Date(labeled.primary);
327 if (isNaN(primaryDate.getTime())) {
328 throw new InvalidRequestError("Invalid date for cursor");
329 }
330 return {
331 primary: primaryDate.toISOString(),
332 };
333 }
334 cursorToLabeledResult(cursor: SingleKeyCursor) {
335 const primaryDate = new Date(cursor.primary);
336 if (isNaN(primaryDate.getTime())) {
337 throw new InvalidRequestError("Malformed cursor: invalid date");
338 }
339 return {
340 primary: primaryDate.toISOString(),
341 };
342 }
343}
344
345export class IsoSortAtKey extends IsoTimeKey<{
346 indexedAt: string;
347}> {
348 constructor() {
349 super();
350 }
351
352 override labelResult(result: { indexedAt: string }) {
353 return { primary: result.indexedAt };
354 }
355}
356
357type KeyResult = { key: string };
358type RkeyLabeledResult = SingleKeyCursor;
359
360export class RkeyKey<RkeyResult = KeyResult> extends GenericSingleKey<
361 RkeyResult,
362 RkeyLabeledResult
363> {
364 constructor() {
365 super("key");
366 }
367
368 labelResult(result: RkeyResult): RkeyLabeledResult;
369 labelResult<RkeyResult extends KeyResult>(result: RkeyResult) {
370 return { primary: result.key };
371 }
372 labeledResultToCursor(labeled: RkeyLabeledResult) {
373 return {
374 primary: labeled.primary,
375 };
376 }
377 cursorToLabeledResult(cursor: SingleKeyCursor) {
378 try {
379 ensureValidRecordKey(cursor.primary);
380 return {
381 primary: cursor.primary,
382 };
383 } catch {
384 throw new InvalidRequestError("Malformed cursor");
385 }
386 }
387}
388
389export class StashKeyKey extends RkeyKey<{
390 key: string;
391}> {
392 constructor() {
393 super();
394 }
395
396 override labelResult(result: { key: string }) {
397 return { primary: result.key };
398 }
399}
400
401type LikeCountCidResult = { likeCount: number; cid: string };
402type LikeCountCidLabeledResult = KeysetCursor;
403
404/**
405 * Custom keyset for paginating by like count with cid as tie-breaker.
406 * Useful for sorting items by popularity or engagement metrics.
407 */
408export class LikeCountCidKeyset extends GenericKeyset<
409 LikeCountCidResult,
410 LikeCountCidLabeledResult
411> {
412 constructor() {
413 super("likeCount", "cid");
414 }
415
416 labelResult(result: LikeCountCidResult): LikeCountCidLabeledResult {
417 return {
418 primary: result.likeCount.toString(),
419 secondary: result.cid,
420 };
421 }
422
423 labeledResultToCursor(labeled: LikeCountCidLabeledResult) {
424 return {
425 primary: labeled.primary,
426 secondary: labeled.secondary,
427 };
428 }
429
430 cursorToLabeledResult(cursor: KeysetCursor) {
431 const likeCount = parseInt(cursor.primary, 10);
432 if (isNaN(likeCount)) {
433 throw new InvalidRequestError("Malformed cursor: invalid like count");
434 }
435 return {
436 primary: cursor.primary,
437 secondary: cursor.secondary,
438 };
439 }
440}