[READ ONLY MIRROR] Spark Social AppView Server github.com/sprksocial/server
atproto deno hono lexicon
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 440 lines 13 kB view raw
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}