an attempt to make a lightweight, easily self-hostable, scoped bluesky appview

even more intense index server instantiation

rimar1337 dae503d2 32d1df0a

Changed files
+1479 -1406
utils
+1463 -1395
indexserver.ts
··· 5 5 import * as IndexServerTypes from "./utils/indexservertypes.ts"; 6 6 import { Database } from "jsr:@db/sqlite@0.11"; 7 7 import { setupUserDb } from "./utils/dbuser.ts"; 8 - import { indexerUserManager, jetstreamurl, systemDB } from "./main.ts"; 8 + // import { systemDB } from "./main.ts"; 9 9 import { JetstreamManager, SpacedustManager } from "./utils/sharders.ts"; 10 10 import { handleSpacedust, SpacedustLinkMessage } from "./index/spacedust.ts"; 11 11 import { handleJetstream } from "./index/jetstream.ts"; ··· 13 13 import { AtUri } from "npm:@atproto/api"; 14 14 import * as IndexServerAPI from "./indexclient/index.ts"; 15 15 16 + export interface IndexServerConfig { 17 + baseDbPath: string; 18 + systemDbPath: string; 19 + jetstreamUrl: string; 20 + } 21 + 22 + interface BaseRow { 23 + uri: string; 24 + did: string; 25 + cid: string | null; 26 + rev: string | null; 27 + createdat: number | null; 28 + indexedat: number; 29 + json: string | null; 30 + } 31 + interface GeneratorRow extends BaseRow { 32 + displayname: string | null; 33 + description: string | null; 34 + avatarcid: string | null; 35 + } 36 + interface LikeRow extends BaseRow { 37 + subject: string; 38 + } 39 + interface RepostRow extends BaseRow { 40 + subject: string; 41 + } 42 + interface BacklinkRow { 43 + srcuri: string; 44 + srcdid: string; 45 + } 46 + 47 + const FEED_LIMIT = 50; 48 + 49 + export class IndexServer { 50 + private config: IndexServerConfig; 51 + public userManager: IndexServerUserManager; 52 + public systemDB: Database; 53 + 54 + constructor(config: IndexServerConfig) { 55 + this.config = config; 56 + 57 + // We will initialize the system DB and user manager here 58 + this.systemDB = new Database(this.config.systemDbPath); 59 + // TODO: We need to setup the system DB schema if it's new 60 + 61 + this.userManager = new IndexServerUserManager(this); // Pass the server instance 62 + } 63 + 64 + public start() { 65 + // This is where we'll kick things off, like the cold start 66 + this.userManager.coldStart(this.systemDB); 67 + console.log("IndexServer started."); 68 + } 69 + 70 + public async handleRequest(req: Request): Promise<Response> { 71 + const url = new URL(req.url); 72 + // We will add routing logic here later to call our handlers 73 + if (url.pathname.startsWith("/xrpc/")) { 74 + return this.indexServerHandler(req); 75 + } 76 + if (url.pathname.startsWith("/links")) { 77 + return this.constellationAPIHandler(req); 78 + } 79 + return new Response("Not Found", { status: 404 }); 80 + } 81 + 82 + // We will move all the global functions into this class as methods... 83 + indexServerHandler(req: Request): Response { 84 + const url = new URL(req.url); 85 + const pathname = url.pathname; 86 + //const bskyUrl = `https://api.bsky.app${pathname}${url.search}`; 87 + //const hasAuth = req.headers.has("authorization"); 88 + const xrpcMethod = pathname.startsWith("/xrpc/") 89 + ? pathname.slice("/xrpc/".length) 90 + : null; 91 + const searchParams = searchParamsToJson(url.searchParams); 92 + console.log(JSON.stringify(searchParams, null, 2)); 93 + const jsonUntyped = searchParams; 94 + 95 + switch (xrpcMethod) { 96 + case "app.bsky.actor.getProfile": { 97 + const jsonTyped = 98 + jsonUntyped as IndexServerTypes.AppBskyActorGetProfile.QueryParams; 99 + 100 + const res = this.queryProfileView(jsonTyped.actor, "Detailed"); 101 + if (!res) 102 + return new Response( 103 + JSON.stringify({ 104 + error: "User not found", 105 + }), 106 + { 107 + status: 404, 108 + headers: withCors({ "Content-Type": "application/json" }), 109 + } 110 + ); 111 + const response: IndexServerTypes.AppBskyActorGetProfile.OutputSchema = 112 + res; 113 + 114 + return new Response(JSON.stringify(response), { 115 + headers: withCors({ "Content-Type": "application/json" }), 116 + }); 117 + } 118 + case "app.bsky.actor.getProfiles": { 119 + const jsonTyped = 120 + jsonUntyped as IndexServerTypes.AppBskyActorGetProfiles.QueryParams; 121 + 122 + if (typeof jsonUntyped?.actors === "string") { 123 + const res = this.queryProfileView( 124 + jsonUntyped.actors as string, 125 + "Detailed" 126 + ); 127 + if (!res) 128 + return new Response( 129 + JSON.stringify({ 130 + error: "User not found", 131 + }), 132 + { 133 + status: 404, 134 + headers: withCors({ "Content-Type": "application/json" }), 135 + } 136 + ); 137 + const response: IndexServerTypes.AppBskyActorGetProfiles.OutputSchema = 138 + { 139 + profiles: [res], 140 + }; 141 + 142 + return new Response(JSON.stringify(response), { 143 + headers: withCors({ "Content-Type": "application/json" }), 144 + }); 145 + } 146 + 147 + const res: ATPAPI.AppBskyActorDefs.ProfileViewDetailed[] = 148 + jsonTyped.actors 149 + .map((actor) => { 150 + return this.queryProfileView(actor, "Detailed"); 151 + }) 152 + .filter( 153 + (x): x is ATPAPI.AppBskyActorDefs.ProfileViewDetailed => 154 + x !== undefined 155 + ); 156 + 157 + if (!res) 158 + return new Response( 159 + JSON.stringify({ 160 + error: "User not found", 161 + }), 162 + { 163 + status: 404, 164 + headers: withCors({ "Content-Type": "application/json" }), 165 + } 166 + ); 167 + 168 + const response: IndexServerTypes.AppBskyActorGetProfiles.OutputSchema = 169 + { 170 + profiles: res, 171 + }; 172 + 173 + return new Response(JSON.stringify(response), { 174 + headers: withCors({ "Content-Type": "application/json" }), 175 + }); 176 + } 177 + case "app.bsky.feed.getActorFeeds": { 178 + const jsonTyped = 179 + jsonUntyped as IndexServerTypes.AppBskyFeedGetActorFeeds.QueryParams; 180 + 181 + const qresult = this.queryActorFeeds(jsonTyped.actor); 182 + 183 + const response: IndexServerTypes.AppBskyFeedGetActorFeeds.OutputSchema = 184 + { 185 + feeds: qresult, 186 + }; 187 + 188 + return new Response(JSON.stringify(response), { 189 + headers: withCors({ "Content-Type": "application/json" }), 190 + }); 191 + } 192 + case "app.bsky.feed.getFeedGenerator": { 193 + const jsonTyped = 194 + jsonUntyped as IndexServerTypes.AppBskyFeedGetFeedGenerator.QueryParams; 195 + 196 + const qresult = this.queryFeedGenerator(jsonTyped.feed); 197 + if (!qresult) { 198 + return new Response( 199 + JSON.stringify({ 200 + error: "Feed not found", 201 + }), 202 + { 203 + status: 404, 204 + headers: withCors({ "Content-Type": "application/json" }), 205 + } 206 + ); 207 + } 208 + 209 + const response: IndexServerTypes.AppBskyFeedGetFeedGenerator.OutputSchema = 210 + { 211 + view: qresult, 212 + isOnline: true, // lmao 213 + isValid: true, // lmao 214 + }; 215 + 216 + return new Response(JSON.stringify(response), { 217 + headers: withCors({ "Content-Type": "application/json" }), 218 + }); 219 + } 220 + case "app.bsky.feed.getFeedGenerators": { 221 + const jsonTyped = 222 + jsonUntyped as IndexServerTypes.AppBskyFeedGetFeedGenerators.QueryParams; 223 + 224 + const qresult = this.queryFeedGenerators(jsonTyped.feeds); 225 + if (!qresult) { 226 + return new Response( 227 + JSON.stringify({ 228 + error: "Feed not found", 229 + }), 230 + { 231 + status: 404, 232 + headers: withCors({ "Content-Type": "application/json" }), 233 + } 234 + ); 235 + } 236 + 237 + const response: IndexServerTypes.AppBskyFeedGetFeedGenerators.OutputSchema = 238 + { 239 + feeds: qresult, 240 + }; 241 + 242 + return new Response(JSON.stringify(response), { 243 + headers: withCors({ "Content-Type": "application/json" }), 244 + }); 245 + } 246 + case "app.bsky.feed.getPosts": { 247 + const jsonTyped = 248 + jsonUntyped as IndexServerTypes.AppBskyFeedGetPosts.QueryParams; 249 + 250 + const posts: IndexServerTypes.AppBskyFeedGetPosts.OutputSchema["posts"] = 251 + jsonTyped.uris 252 + .map((uri) => { 253 + return this.queryPostView(uri); 254 + }) 255 + .filter(Boolean) as ATPAPI.AppBskyFeedDefs.PostView[]; 256 + 257 + const response: IndexServerTypes.AppBskyFeedGetPosts.OutputSchema = { 258 + posts, 259 + }; 260 + 261 + return new Response(JSON.stringify(response), { 262 + headers: withCors({ "Content-Type": "application/json" }), 263 + }); 264 + } 265 + case "party.whey.app.bsky.feed.getActorLikesPartial": { 266 + const jsonTyped = 267 + jsonUntyped as IndexServerTypes.PartyWheyAppBskyFeedGetActorLikesPartial.QueryParams; 268 + 269 + // TODO: not partial yet, currently skips refs 270 + 271 + const qresult = this.queryActorLikes(jsonTyped.actor, jsonTyped.cursor); 272 + if (!qresult) { 273 + return new Response( 274 + JSON.stringify({ 275 + error: "Feed not found", 276 + }), 277 + { 278 + status: 404, 279 + headers: withCors({ "Content-Type": "application/json" }), 280 + } 281 + ); 282 + } 283 + 284 + const response: IndexServerTypes.PartyWheyAppBskyFeedGetActorLikesPartial.OutputSchema = 285 + { 286 + feed: qresult.items as ATPAPI.$Typed<ATPAPI.AppBskyFeedDefs.FeedViewPost>[], 287 + cursor: qresult.cursor, 288 + }; 289 + 290 + return new Response(JSON.stringify(response), { 291 + headers: withCors({ "Content-Type": "application/json" }), 292 + }); 293 + } 294 + case "party.whey.app.bsky.feed.getAuthorFeedPartial": { 295 + const jsonTyped = 296 + jsonUntyped as IndexServerTypes.PartyWheyAppBskyFeedGetAuthorFeedPartial.QueryParams; 297 + 298 + // TODO: not partial yet, currently skips refs 299 + 300 + const qresult = this.queryAuthorFeed(jsonTyped.actor, jsonTyped.cursor); 301 + if (!qresult) { 302 + return new Response( 303 + JSON.stringify({ 304 + error: "Feed not found", 305 + }), 306 + { 307 + status: 404, 308 + headers: withCors({ "Content-Type": "application/json" }), 309 + } 310 + ); 311 + } 312 + 313 + const response: IndexServerTypes.PartyWheyAppBskyFeedGetAuthorFeedPartial.OutputSchema = 314 + { 315 + feed: qresult.items as ATPAPI.$Typed<ATPAPI.AppBskyFeedDefs.FeedViewPost>[], 316 + cursor: qresult.cursor, 317 + }; 318 + 319 + return new Response(JSON.stringify(response), { 320 + headers: withCors({ "Content-Type": "application/json" }), 321 + }); 322 + } 323 + case "party.whey.app.bsky.feed.getLikesPartial": { 324 + const jsonTyped = 325 + jsonUntyped as IndexServerTypes.PartyWheyAppBskyFeedGetLikesPartial.QueryParams; 326 + 327 + // TODO: not partial yet, currently skips refs 328 + 329 + const qresult = this.queryLikes(jsonTyped.uri); 330 + if (!qresult) { 331 + return new Response( 332 + JSON.stringify({ 333 + error: "Feed not found", 334 + }), 335 + { 336 + status: 404, 337 + headers: withCors({ "Content-Type": "application/json" }), 338 + } 339 + ); 340 + } 341 + const response: IndexServerTypes.PartyWheyAppBskyFeedGetLikesPartial.OutputSchema = 342 + { 343 + // @ts-ignore whatever i dont care TODO: fix ts ignores 344 + likes: qresult, 345 + }; 346 + 347 + return new Response(JSON.stringify(response), { 348 + headers: withCors({ "Content-Type": "application/json" }), 349 + }); 350 + } 351 + case "party.whey.app.bsky.feed.getPostThreadPartial": { 352 + const jsonTyped = 353 + jsonUntyped as IndexServerTypes.PartyWheyAppBskyFeedGetPostThreadPartial.QueryParams; 354 + 355 + // TODO: not partial yet, currently skips refs 356 + 357 + const qresult = this.queryPostThread(jsonTyped.uri); 358 + if (!qresult) { 359 + return new Response( 360 + JSON.stringify({ 361 + error: "Feed not found", 362 + }), 363 + { 364 + status: 404, 365 + headers: withCors({ "Content-Type": "application/json" }), 366 + } 367 + ); 368 + } 369 + const response: IndexServerTypes.PartyWheyAppBskyFeedGetPostThreadPartial.OutputSchema = 370 + qresult; 371 + 372 + return new Response(JSON.stringify(response), { 373 + headers: withCors({ "Content-Type": "application/json" }), 374 + }); 375 + } 376 + case "party.whey.app.bsky.feed.getQuotesPartial": { 377 + const jsonTyped = 378 + jsonUntyped as IndexServerTypes.PartyWheyAppBskyFeedGetQuotesPartial.QueryParams; 379 + 380 + // TODO: not partial yet, currently skips refs 381 + 382 + const qresult = this.queryQuotes(jsonTyped.uri); 383 + if (!qresult) { 384 + return new Response( 385 + JSON.stringify({ 386 + error: "Feed not found", 387 + }), 388 + { 389 + status: 404, 390 + headers: withCors({ "Content-Type": "application/json" }), 391 + } 392 + ); 393 + } 394 + const response: IndexServerTypes.PartyWheyAppBskyFeedGetQuotesPartial.OutputSchema = 395 + { 396 + uri: jsonTyped.uri, 397 + posts: qresult.map((feedviewpost) => { 398 + return feedviewpost.post as ATPAPI.$Typed<ATPAPI.AppBskyFeedDefs.PostView>; 399 + }), 400 + }; 401 + 402 + return new Response(JSON.stringify(response), { 403 + headers: withCors({ "Content-Type": "application/json" }), 404 + }); 405 + } 406 + case "party.whey.app.bsky.feed.getRepostedByPartial": { 407 + const jsonTyped = 408 + jsonUntyped as IndexServerTypes.PartyWheyAppBskyFeedGetRepostedByPartial.QueryParams; 409 + 410 + // TODO: not partial yet, currently skips refs 411 + 412 + const qresult = this.queryReposts(jsonTyped.uri); 413 + if (!qresult) { 414 + return new Response( 415 + JSON.stringify({ 416 + error: "Feed not found", 417 + }), 418 + { 419 + status: 404, 420 + headers: withCors({ "Content-Type": "application/json" }), 421 + } 422 + ); 423 + } 424 + const response: IndexServerTypes.PartyWheyAppBskyFeedGetRepostedByPartial.OutputSchema = 425 + { 426 + uri: jsonTyped.uri, 427 + repostedBy: 428 + qresult as ATPAPI.$Typed<ATPAPI.AppBskyActorDefs.ProfileView>[], 429 + }; 430 + 431 + return new Response(JSON.stringify(response), { 432 + headers: withCors({ "Content-Type": "application/json" }), 433 + }); 434 + } 435 + // TODO: too hard for now 436 + // case "party.whey.app.bsky.feed.getListFeedPartial": { 437 + // const jsonTyped = 438 + // jsonUntyped as IndexServerTypes.PartyWheyAppBskyFeedGetListFeedPartial.QueryParams; 439 + 440 + // const response: IndexServerTypes.PartyWheyAppBskyFeedGetListFeedPartial.OutputSchema = 441 + // {}; 442 + 443 + // return new Response(JSON.stringify(response), { 444 + // headers: withCors({ "Content-Type": "application/json" }), 445 + // }); 446 + // } 447 + /* three more coming soon 448 + app.bsky.graph.getLists 449 + app.bsky.graph.getList 450 + app.bsky.graph.getActorStarterPacks 451 + */ 452 + default: { 453 + return new Response( 454 + JSON.stringify({ 455 + error: "XRPCNotSupported", 456 + message: 457 + "HEY hello there my name is whey dot party and you have used my custom appview that is very cool but have you considered that XRPC Not Supported", 458 + }), 459 + { 460 + status: 404, 461 + headers: withCors({ "Content-Type": "application/json" }), 462 + } 463 + ); 464 + } 465 + } 466 + 467 + // return new Response("Not Found", { status: 404 }); 468 + } 469 + 470 + constellationAPIHandler(req: Request): Response { 471 + const url = new URL(req.url); 472 + const pathname = url.pathname; 473 + const searchParams = searchParamsToJson(url.searchParams) as linksQuery; 474 + const jsonUntyped = searchParams; 475 + 476 + if (!jsonUntyped.target) { 477 + return new Response( 478 + JSON.stringify({ error: "Missing required parameter: target" }), 479 + { 480 + status: 400, 481 + headers: withCors({ "Content-Type": "application/json" }), 482 + } 483 + ); 484 + } 485 + 486 + const did = isDid(searchParams.target) 487 + ? searchParams.target 488 + : new AtUri(searchParams.target).host; 489 + const db = this.userManager.getDbForDid(did); 490 + if (!db) { 491 + return new Response( 492 + JSON.stringify({ 493 + error: "User not found", 494 + }), 495 + { 496 + status: 404, 497 + headers: withCors({ "Content-Type": "application/json" }), 498 + } 499 + ); 500 + } 501 + 502 + const limit = 16; //Math.min(parseInt(searchParams.limit || "50", 10), 100); 503 + const offset = parseInt(searchParams.cursor || "0", 10); 504 + 505 + switch (pathname) { 506 + case "/links": { 507 + const jsonTyped = jsonUntyped as linksQuery; 508 + if (!jsonTyped.collection || !jsonTyped.path) { 509 + return new Response( 510 + JSON.stringify({ 511 + error: "Missing required parameters: collection, path", 512 + }), 513 + { 514 + status: 400, 515 + headers: withCors({ "Content-Type": "application/json" }), 516 + } 517 + ); 518 + } 519 + 520 + const field = `${jsonTyped.collection}:${jsonTyped.path.replace( 521 + /^\./, 522 + "" 523 + )}`; 524 + 525 + const paginatedSql = `${SQL.links} LIMIT ? OFFSET ?`; 526 + const rows = db 527 + .prepare(paginatedSql) 528 + .all(jsonTyped.target, jsonTyped.collection, field, limit, offset); 529 + 530 + const countResult = db 531 + .prepare(SQL.count) 532 + .get(jsonTyped.target, jsonTyped.collection, field); 533 + const total = countResult ? Number(countResult.total) : 0; 534 + 535 + const linking_records: linksRecord[] = rows.map((row: any) => { 536 + const rkey = row.srcuri.split("/").pop()!; 537 + return { 538 + did: row.srcdid, 539 + collection: row.srccol, 540 + rkey, 541 + }; 542 + }); 543 + 544 + const response: linksRecordsResponse = { 545 + total: total.toString(), 546 + linking_records, 547 + }; 548 + 549 + const nextCursor = offset + linking_records.length; 550 + if (nextCursor < total) { 551 + response.cursor = nextCursor.toString(); 552 + } 553 + 554 + return new Response(JSON.stringify(response), { 555 + headers: withCors({ "Content-Type": "application/json" }), 556 + }); 557 + } 558 + case "/links/distinct-dids": { 559 + const jsonTyped = jsonUntyped as linksQuery; 560 + if (!jsonTyped.collection || !jsonTyped.path) { 561 + return new Response( 562 + JSON.stringify({ 563 + error: "Missing required parameters: collection, path", 564 + }), 565 + { 566 + status: 400, 567 + headers: withCors({ "Content-Type": "application/json" }), 568 + } 569 + ); 570 + } 571 + 572 + const field = `${jsonTyped.collection}:${jsonTyped.path.replace( 573 + /^\./, 574 + "" 575 + )}`; 576 + 577 + const paginatedSql = `${SQL.distinctDids} LIMIT ? OFFSET ?`; 578 + const rows = db 579 + .prepare(paginatedSql) 580 + .all(jsonTyped.target, jsonTyped.collection, field, limit, offset); 581 + 582 + const countResult = db 583 + .prepare(SQL.countDistinctDids) 584 + .get(jsonTyped.target, jsonTyped.collection, field); 585 + const total = countResult ? Number(countResult.total) : 0; 586 + 587 + const linking_dids: string[] = rows.map((row: any) => row.srcdid); 588 + 589 + const response: linksDidsResponse = { 590 + total: total.toString(), 591 + linking_dids, 592 + }; 593 + 594 + const nextCursor = offset + linking_dids.length; 595 + if (nextCursor < total) { 596 + response.cursor = nextCursor.toString(); 597 + } 598 + 599 + return new Response(JSON.stringify(response), { 600 + headers: withCors({ "Content-Type": "application/json" }), 601 + }); 602 + } 603 + case "/links/count": { 604 + const jsonTyped = jsonUntyped as linksQuery; 605 + if (!jsonTyped.collection || !jsonTyped.path) { 606 + return new Response( 607 + JSON.stringify({ 608 + error: "Missing required parameters: collection, path", 609 + }), 610 + { 611 + status: 400, 612 + headers: withCors({ "Content-Type": "application/json" }), 613 + } 614 + ); 615 + } 616 + 617 + const field = `${jsonTyped.collection}:${jsonTyped.path.replace( 618 + /^\./, 619 + "" 620 + )}`; 621 + 622 + const result = db 623 + .prepare(SQL.count) 624 + .get(jsonTyped.target, jsonTyped.collection, field); 625 + 626 + const response: linksCountResponse = { 627 + total: result && result.total ? result.total.toString() : "0", 628 + }; 629 + 630 + return new Response(JSON.stringify(response), { 631 + headers: withCors({ "Content-Type": "application/json" }), 632 + }); 633 + } 634 + case "/links/count/distinct-dids": { 635 + const jsonTyped = jsonUntyped as linksQuery; 636 + if (!jsonTyped.collection || !jsonTyped.path) { 637 + return new Response( 638 + JSON.stringify({ 639 + error: "Missing required parameters: collection, path", 640 + }), 641 + { 642 + status: 400, 643 + headers: withCors({ "Content-Type": "application/json" }), 644 + } 645 + ); 646 + } 647 + 648 + const field = `${jsonTyped.collection}:${jsonTyped.path.replace( 649 + /^\./, 650 + "" 651 + )}`; 652 + 653 + const result = db 654 + .prepare(SQL.countDistinctDids) 655 + .get(jsonTyped.target, jsonTyped.collection, field); 656 + 657 + const response: linksCountResponse = { 658 + total: result && result.total ? result.total.toString() : "0", 659 + }; 660 + 661 + return new Response(JSON.stringify(response), { 662 + headers: withCors({ "Content-Type": "application/json" }), 663 + }); 664 + } 665 + case "/links/all": { 666 + const jsonTyped = jsonUntyped as linksAllQuery; 667 + 668 + const rows = db.prepare(SQL.all).all(jsonTyped.target) as any[]; 669 + 670 + const links: linksAllResponse["links"] = {}; 671 + 672 + for (const row of rows) { 673 + if (!links[row.suburi]) { 674 + links[row.suburi] = {}; 675 + } 676 + links[row.suburi][row.srccol] = { 677 + records: row.records, 678 + distinct_dids: row.distinct_dids, 679 + }; 680 + } 681 + 682 + const response: linksAllResponse = { 683 + links, 684 + }; 685 + 686 + return new Response(JSON.stringify(response), { 687 + headers: withCors({ "Content-Type": "application/json" }), 688 + }); 689 + } 690 + default: { 691 + return new Response( 692 + JSON.stringify({ 693 + error: "NotSupported", 694 + message: 695 + "The requested endpoint is not supported by this Constellation implementation.", 696 + }), 697 + { 698 + status: 404, 699 + headers: withCors({ "Content-Type": "application/json" }), 700 + } 701 + ); 702 + } 703 + } 704 + } 705 + 706 + indexServerIndexer(ctx: indexHandlerContext) { 707 + const record = assertRecord(ctx.value); 708 + //const record = validateRecord(ctx.value); 709 + const db = this.userManager.getDbForDid(ctx.doer); 710 + if (!db) return; 711 + console.log("indexering"); 712 + switch (record?.$type) { 713 + case "app.bsky.feed.like": { 714 + return; 715 + } 716 + case "app.bsky.actor.profile": { 717 + console.log("bsky profuile"); 718 + 719 + try { 720 + const stmt = db.prepare(` 721 + INSERT OR IGNORE INTO app_bsky_actor_profile ( 722 + uri, did, cid, rev, createdat, indexedat, json, 723 + displayname, 724 + description, 725 + avatarcid, 726 + avatarmime, 727 + bannercid, 728 + bannermime 729 + ) VALUES (?, ?, ?, ?, ?, ?, ?, 730 + ?, ?, ?, 731 + ?, ?, ?) 732 + `); 733 + console.log({ 734 + uri: ctx.aturi, 735 + did: ctx.doer, 736 + cid: ctx.cid, 737 + rev: ctx.rev, 738 + createdat: record.createdAt, 739 + indexedat: Date.now(), 740 + json: JSON.stringify(record), 741 + displayname: record.displayName, 742 + description: record.description, 743 + avatarcid: uncid(record.avatar?.ref), 744 + avatarmime: record.avatar?.mimeType, 745 + bannercid: uncid(record.banner?.ref), 746 + bannermime: record.banner?.mimeType, 747 + }); 748 + stmt.run( 749 + ctx.aturi ?? null, 750 + ctx.doer ?? null, 751 + ctx.cid ?? null, 752 + ctx.rev ?? null, 753 + record.createdAt ?? null, 754 + Date.now(), 755 + JSON.stringify(record), 756 + 757 + record.displayName ?? null, 758 + record.description ?? null, 759 + uncid(record.avatar?.ref) ?? null, 760 + record.avatar?.mimeType ?? null, 761 + uncid(record.banner?.ref) ?? null, 762 + record.banner?.mimeType ?? null 763 + // TODO please add pinned posts 764 + ); 765 + } catch (err) { 766 + console.error("stmt.run failed:", err); 767 + } 768 + return; 769 + } 770 + case "app.bsky.feed.post": { 771 + console.log("bsky post"); 772 + const stmt = db.prepare(` 773 + INSERT OR IGNORE INTO app_bsky_feed_post ( 774 + uri, did, cid, rev, createdat, indexedat, json, 775 + text, replyroot, replyparent, quote, 776 + imagecount, image1cid, image1mime, image1aspect, 777 + image2cid, image2mime, image2aspect, 778 + image3cid, image3mime, image3aspect, 779 + image4cid, image4mime, image4aspect, 780 + videocount, videocid, videomime, videoaspect 781 + ) VALUES (?, ?, ?, ?, ?, ?, ?, 782 + ?, ?, ?, ?, 783 + ?, ?, ?, ?, 784 + ?, ?, ?, 785 + ?, ?, ?, 786 + ?, ?, ?, 787 + ?, ?, ?, ?) 788 + `); 789 + 790 + const embed = record.embed; 791 + 792 + const images = extractImages(embed); 793 + const video = extractVideo(embed); 794 + const quoteUri = extractQuoteUri(embed); 795 + try { 796 + stmt.run( 797 + ctx.aturi ?? null, 798 + ctx.doer ?? null, 799 + ctx.cid ?? null, 800 + ctx.rev ?? null, 801 + record.createdAt, 802 + Date.now(), 803 + JSON.stringify(record), 804 + 805 + record.text ?? null, 806 + record.reply?.root?.uri ?? null, 807 + record.reply?.parent?.uri ?? null, 808 + 809 + quoteUri, 810 + 811 + images.length, 812 + uncid(images[0]?.image?.ref) ?? null, 813 + images[0]?.image?.mimeType ?? null, 814 + images[0]?.aspectRatio && 815 + images[0].aspectRatio.width && 816 + images[0].aspectRatio.height 817 + ? `${images[0].aspectRatio.width}:${images[0].aspectRatio.height}` 818 + : null, 819 + 820 + uncid(images[1]?.image?.ref) ?? null, 821 + images[1]?.image?.mimeType ?? null, 822 + images[1]?.aspectRatio && 823 + images[1].aspectRatio.width && 824 + images[1].aspectRatio.height 825 + ? `${images[1].aspectRatio.width}:${images[1].aspectRatio.height}` 826 + : null, 827 + 828 + uncid(images[2]?.image?.ref) ?? null, 829 + images[2]?.image?.mimeType ?? null, 830 + images[2]?.aspectRatio && 831 + images[2].aspectRatio.width && 832 + images[2].aspectRatio.height 833 + ? `${images[2].aspectRatio.width}:${images[2].aspectRatio.height}` 834 + : null, 835 + 836 + uncid(images[3]?.image?.ref) ?? null, 837 + images[3]?.image?.mimeType ?? null, 838 + images[3]?.aspectRatio && 839 + images[3].aspectRatio.width && 840 + images[3].aspectRatio.height 841 + ? `${images[3].aspectRatio.width}:${images[3].aspectRatio.height}` 842 + : null, 843 + 844 + uncid(video?.video) ? 1 : 0, 845 + uncid(video?.video) ?? null, 846 + uncid(video?.video) ? "video/mp4" : null, 847 + video?.aspectRatio 848 + ? `${video.aspectRatio.width}:${video.aspectRatio.height}` 849 + : null 850 + ); 851 + } catch (err) { 852 + console.error("stmt.run failed:", err); 853 + } 854 + return; 855 + } 856 + default: { 857 + // what the hell 858 + return; 859 + } 860 + } 861 + } 862 + 863 + // user data 864 + queryProfileView( 865 + did: string, 866 + type: "" 867 + ): ATPAPI.AppBskyActorDefs.ProfileView | undefined; 868 + queryProfileView( 869 + did: string, 870 + type: "Basic" 871 + ): ATPAPI.AppBskyActorDefs.ProfileViewBasic | undefined; 872 + queryProfileView( 873 + did: string, 874 + type: "Detailed" 875 + ): ATPAPI.AppBskyActorDefs.ProfileViewDetailed | undefined; 876 + queryProfileView( 877 + did: string, 878 + type: "" | "Basic" | "Detailed" 879 + ): 880 + | ATPAPI.AppBskyActorDefs.ProfileView 881 + | ATPAPI.AppBskyActorDefs.ProfileViewBasic 882 + | ATPAPI.AppBskyActorDefs.ProfileViewDetailed 883 + | undefined { 884 + if (!this.isRegisteredIndexUser(did)) return; 885 + const db = this.userManager.getDbForDid(did); 886 + if (!db) return; 887 + 888 + const stmt = db.prepare(` 889 + SELECT * 890 + FROM app_bsky_actor_profile 891 + WHERE did = ? 892 + LIMIT 1; 893 + `); 894 + 895 + const row = stmt.get(did) as ProfileRow; 896 + 897 + // simulate different types returned 898 + switch (type) { 899 + case "": { 900 + const result: ATPAPI.AppBskyActorDefs.ProfileView = { 901 + $type: "app.bsky.actor.defs#profileView", 902 + did: did, 903 + handle: "idiot.fuck.shit.example.com", // TODO: Resolve user identity here for the handle 904 + displayName: row.displayname ?? undefined, 905 + description: row.description ?? undefined, 906 + avatar: "https://google.com/", // create profile URL from resolved identity 907 + //associated?: ProfileAssociated, 908 + indexedAt: row.createdat 909 + ? new Date(row.createdat).toISOString() 910 + : undefined, 911 + createdAt: row.createdat 912 + ? new Date(row.createdat).toISOString() 913 + : undefined, 914 + //viewer?: ViewerState, 915 + //labels?: ComAtprotoLabelDefs.Label[], 916 + //verification?: VerificationState, 917 + //status?: StatusView, 918 + }; 919 + return result; 920 + } 921 + case "Basic": { 922 + const result: ATPAPI.AppBskyActorDefs.ProfileViewBasic = { 923 + $type: "app.bsky.actor.defs#profileViewBasic", 924 + did: did, 925 + handle: "idiot.fuck.shit.example.com", // TODO: Resolve user identity here for the handle 926 + displayName: row.displayname ?? undefined, 927 + avatar: "https://google.com/", // create profile URL from resolved identity 928 + //associated?: ProfileAssociated, 929 + createdAt: row.createdat 930 + ? new Date(row.createdat).toISOString() 931 + : undefined, 932 + //viewer?: ViewerState, 933 + //labels?: ComAtprotoLabelDefs.Label[], 934 + //verification?: VerificationState, 935 + //status?: StatusView, 936 + }; 937 + return result; 938 + } 939 + case "Detailed": { 940 + // Query for follower count from the backlink_skeleton table 941 + const followersStmt = db.prepare(` 942 + SELECT COUNT(*) as count 943 + FROM backlink_skeleton 944 + WHERE subdid = ? AND srccol = 'app.bsky.graph.follow' 945 + `); 946 + const followersResult = followersStmt.get(did) as { count: number }; 947 + const followersCount = followersResult?.count ?? 0; 948 + 949 + // Query for following count from the app_bsky_graph_follow table 950 + const followingStmt = db.prepare(` 951 + SELECT COUNT(*) as count 952 + FROM app_bsky_graph_follow 953 + WHERE did = ? 954 + `); 955 + const followingResult = followingStmt.get(did) as { count: number }; 956 + const followsCount = followingResult?.count ?? 0; 957 + 958 + // Query for post count from the app_bsky_feed_post table 959 + const postsStmt = db.prepare(` 960 + SELECT COUNT(*) as count 961 + FROM app_bsky_feed_post 962 + WHERE did = ? 963 + `); 964 + const postsResult = postsStmt.get(did) as { count: number }; 965 + const postsCount = postsResult?.count ?? 0; 966 + 967 + const result: ATPAPI.AppBskyActorDefs.ProfileViewDetailed = { 968 + $type: "app.bsky.actor.defs#profileViewDetailed", 969 + did: did, 970 + handle: "idiot.fuck.shit.example.com", // TODO: Resolve user identity here for the handle 971 + displayName: row.displayname ?? undefined, 972 + description: row.description ?? undefined, 973 + avatar: "https://google.com/", // TODO: create profile URL from resolved identity 974 + banner: "https://youtube.com/", // same here 975 + followersCount: followersCount, 976 + followsCount: followsCount, 977 + postsCount: postsCount, 978 + //associated?: ProfileAssociated, 979 + //joinedViaStarterPack?: // AppBskyGraphDefs.StarterPackViewBasic; 980 + indexedAt: row.createdat 981 + ? new Date(row.createdat).toISOString() 982 + : undefined, 983 + createdAt: row.createdat 984 + ? new Date(row.createdat).toISOString() 985 + : undefined, 986 + //viewer?: ViewerState, 987 + //labels?: ComAtprotoLabelDefs.Label[], 988 + pinnedPost: undefined, //row.; // TODO: i forgot to put pinnedp posts in db schema oops 989 + //verification?: VerificationState, 990 + //status?: StatusView, 991 + }; 992 + return result; 993 + } 994 + default: 995 + throw new Error("Invalid type"); 996 + } 997 + } 998 + 999 + // post hydration 1000 + queryPostView(uri: string): ATPAPI.AppBskyFeedDefs.PostView | undefined { 1001 + const URI = new AtUri(uri); 1002 + const did = URI.host; 1003 + if (!this.isRegisteredIndexUser(did)) return; 1004 + const db = this.userManager.getDbForDid(did); 1005 + if (!db) return; 1006 + 1007 + const stmt = db.prepare(` 1008 + SELECT * 1009 + FROM app_bsky_feed_post 1010 + WHERE uri = ? 1011 + LIMIT 1; 1012 + `); 1013 + 1014 + const row = stmt.get(uri) as PostRow; 1015 + const profileView = this.queryProfileView(did, "Basic"); 1016 + if (!row || !row.cid || !profileView || !row.json) return; 1017 + const value = JSON.parse(row.json) as ATPAPI.AppBskyFeedPost.Record; 1018 + 1019 + const post: ATPAPI.AppBskyFeedDefs.PostView = { 1020 + uri: row.uri, 1021 + cid: row.cid, 1022 + author: profileView, 1023 + record: value, 1024 + indexedAt: new Date(row.indexedat).toISOString(), 1025 + embed: value.embed, 1026 + }; 1027 + 1028 + return post; 1029 + } 1030 + queryFeedViewPost( 1031 + uri: string 1032 + ): ATPAPI.AppBskyFeedDefs.FeedViewPost | undefined { 1033 + const post = this.queryPostView(uri); 1034 + if (!post) return; 1035 + 1036 + const feedviewpost: ATPAPI.AppBskyFeedDefs.FeedViewPost = { 1037 + $type: "app.bsky.feed.defs#feedViewPost", 1038 + post: post, 1039 + //reply: ReplyRef, 1040 + //reason: , 1041 + }; 1042 + 1043 + return feedviewpost; 1044 + } 1045 + 1046 + // user feedgens 1047 + 1048 + queryActorFeeds(did: string): ATPAPI.AppBskyFeedDefs.GeneratorView[] { 1049 + if (!this.isRegisteredIndexUser(did)) return []; 1050 + const db = this.userManager.getDbForDid(did); 1051 + if (!db) return []; 1052 + 1053 + const stmt = db.prepare(` 1054 + SELECT uri, cid, did, json, indexedat 1055 + FROM app_bsky_feed_generator 1056 + WHERE did = ? 1057 + ORDER BY createdat DESC; 1058 + `); 1059 + 1060 + const rows = stmt.all(did) as unknown as GeneratorRow[]; 1061 + const creatorView = this.queryProfileView(did, "Basic"); 1062 + if (!creatorView) return []; 1063 + 1064 + return rows 1065 + .map((row) => { 1066 + try { 1067 + if (!row.json) return; 1068 + const record = JSON.parse( 1069 + row.json 1070 + ) as ATPAPI.AppBskyFeedGenerator.Record; 1071 + return { 1072 + $type: "app.bsky.feed.defs#generatorView", 1073 + uri: row.uri, 1074 + cid: row.cid, 1075 + did: row.did, 1076 + creator: creatorView, 1077 + displayName: record.displayName, 1078 + description: record.description, 1079 + descriptionFacets: record.descriptionFacets, 1080 + avatar: record.avatar, 1081 + likeCount: 0, // TODO: this should be easy 1082 + indexedAt: new Date(row.indexedat).toISOString(), 1083 + } as ATPAPI.AppBskyFeedDefs.GeneratorView; 1084 + } catch { 1085 + return undefined; 1086 + } 1087 + }) 1088 + .filter((v): v is ATPAPI.AppBskyFeedDefs.GeneratorView => !!v); 1089 + } 1090 + 1091 + queryFeedGenerator( 1092 + uri: string 1093 + ): ATPAPI.AppBskyFeedDefs.GeneratorView | undefined { 1094 + return this.queryFeedGenerators([uri])[0]; 1095 + } 1096 + 1097 + queryFeedGenerators(uris: string[]): ATPAPI.AppBskyFeedDefs.GeneratorView[] { 1098 + const generators: ATPAPI.AppBskyFeedDefs.GeneratorView[] = []; 1099 + const urisByDid = new Map<string, string[]>(); 1100 + 1101 + for (const uri of uris) { 1102 + try { 1103 + const { host: did } = new AtUri(uri); 1104 + if (!urisByDid.has(did)) { 1105 + urisByDid.set(did, []); 1106 + } 1107 + urisByDid.get(did)!.push(uri); 1108 + } catch {} 1109 + } 1110 + 1111 + for (const [did, didUris] of urisByDid.entries()) { 1112 + if (!this.isRegisteredIndexUser(did)) continue; 1113 + const db = this.userManager.getDbForDid(did); 1114 + if (!db) continue; 1115 + 1116 + const placeholders = didUris.map(() => "?").join(","); 1117 + const stmt = db.prepare(` 1118 + SELECT uri, cid, did, json, indexedat 1119 + FROM app_bsky_feed_generator 1120 + WHERE uri IN (${placeholders}); 1121 + `); 1122 + 1123 + const rows = stmt.all(...didUris) as unknown as GeneratorRow[]; 1124 + if (rows.length === 0) continue; 1125 + 1126 + const creatorView = this.queryProfileView(did, ""); 1127 + if (!creatorView) continue; 1128 + 1129 + for (const row of rows) { 1130 + try { 1131 + if (!row.json || !row.cid) continue; 1132 + const record = JSON.parse( 1133 + row.json 1134 + ) as ATPAPI.AppBskyFeedGenerator.Record; 1135 + generators.push({ 1136 + $type: "app.bsky.feed.defs#generatorView", 1137 + uri: row.uri, 1138 + cid: row.cid, 1139 + did: row.did, 1140 + creator: creatorView, 1141 + displayName: record.displayName, 1142 + description: record.description, 1143 + descriptionFacets: record.descriptionFacets, 1144 + avatar: record.avatar as string | undefined, 1145 + likeCount: 0, 1146 + indexedAt: new Date(row.indexedat).toISOString(), 1147 + }); 1148 + } catch {} 1149 + } 1150 + } 1151 + return generators; 1152 + } 1153 + 1154 + // user feeds 1155 + 1156 + queryAuthorFeed( 1157 + did: string, 1158 + cursor?: string 1159 + ): 1160 + | { 1161 + items: ATPAPI.AppBskyFeedDefs.FeedViewPost[]; 1162 + cursor: string | undefined; 1163 + } 1164 + | undefined { 1165 + if (!this.isRegisteredIndexUser(did)) return; 1166 + const db = this.userManager.getDbForDid(did); 1167 + if (!db) return; 1168 + 1169 + // TODO: implement this for real 1170 + let query = ` 1171 + SELECT uri, indexedat, cid 1172 + FROM app_bsky_feed_post 1173 + WHERE did = ? 1174 + `; 1175 + const params: (string | number)[] = [did]; 1176 + 1177 + if (cursor) { 1178 + const [indexedat, cid] = cursor.split("::"); 1179 + query += ` AND (indexedat < ? OR (indexedat = ? AND cid < ?))`; 1180 + params.push(parseInt(indexedat, 10), parseInt(indexedat, 10), cid); 1181 + } 1182 + 1183 + query += ` ORDER BY indexedat DESC, cid DESC LIMIT ${FEED_LIMIT}`; 1184 + 1185 + const stmt = db.prepare(query); 1186 + const rows = stmt.all(...params) as { 1187 + uri: string; 1188 + indexedat: number; 1189 + cid: string; 1190 + }[]; 1191 + 1192 + const items = rows 1193 + .map((row) => this.queryFeedViewPost(row.uri)) // TODO: for replies and repost i should inject the reason here 1194 + .filter((p): p is ATPAPI.AppBskyFeedDefs.FeedViewPost => !!p); 1195 + 1196 + const lastItem = rows[rows.length - 1]; 1197 + const nextCursor = lastItem 1198 + ? `${lastItem.indexedat}::${lastItem.cid}` 1199 + : undefined; 1200 + 1201 + return { items, cursor: nextCursor }; 1202 + } 1203 + 1204 + queryListFeed( 1205 + uri: string, 1206 + cursor?: string 1207 + ): 1208 + | { 1209 + items: ATPAPI.AppBskyFeedDefs.FeedViewPost[]; 1210 + cursor: string | undefined; 1211 + } 1212 + | undefined { 1213 + return { items: [], cursor: undefined }; 1214 + } 1215 + 1216 + queryActorLikes( 1217 + did: string, 1218 + cursor?: string 1219 + ): 1220 + | { 1221 + items: ATPAPI.AppBskyFeedDefs.FeedViewPost[]; 1222 + cursor: string | undefined; 1223 + } 1224 + | undefined { 1225 + if (!this.isRegisteredIndexUser(did)) return; 1226 + const db = this.userManager.getDbForDid(did); 1227 + if (!db) return; 1228 + 1229 + let query = ` 1230 + SELECT subject, indexedat, cid 1231 + FROM app_bsky_feed_like 1232 + WHERE did = ? 1233 + `; 1234 + const params: (string | number)[] = [did]; 1235 + 1236 + if (cursor) { 1237 + const [indexedat, cid] = cursor.split("::"); 1238 + query += ` AND (indexedat < ? OR (indexedat = ? AND cid < ?))`; 1239 + params.push(parseInt(indexedat, 10), parseInt(indexedat, 10), cid); 1240 + } 1241 + 1242 + query += ` ORDER BY indexedat DESC, cid DESC LIMIT ${FEED_LIMIT}`; 1243 + 1244 + const stmt = db.prepare(query); 1245 + const rows = stmt.all(...params) as { 1246 + subject: string; 1247 + indexedat: number; 1248 + cid: string; 1249 + }[]; 1250 + 1251 + const items = rows 1252 + .map((row) => this.queryFeedViewPost(row.subject)) 1253 + .filter((p): p is ATPAPI.AppBskyFeedDefs.FeedViewPost => !!p); 1254 + 1255 + const lastItem = rows[rows.length - 1]; 1256 + const nextCursor = lastItem 1257 + ? `${lastItem.indexedat}::${lastItem.cid}` 1258 + : undefined; 1259 + 1260 + return { items, cursor: nextCursor }; 1261 + } 1262 + 1263 + // post metadata 1264 + 1265 + queryLikes(uri: string): ATPAPI.AppBskyFeedGetLikes.Like[] | undefined { 1266 + const postUri = new AtUri(uri); 1267 + const postAuthorDid = postUri.hostname; 1268 + if (!this.isRegisteredIndexUser(postAuthorDid)) return; 1269 + const db = this.userManager.getDbForDid(postAuthorDid); 1270 + if (!db) return; 1271 + 1272 + const stmt = db.prepare(` 1273 + SELECT b.srcdid, b.srcuri 1274 + FROM backlink_skeleton AS b 1275 + WHERE b.suburi = ? AND b.srccol = 'app_bsky_feed_like' 1276 + ORDER BY b.id DESC; 1277 + `); 1278 + 1279 + const rows = stmt.all(uri) as unknown as BacklinkRow[]; 1280 + 1281 + return rows 1282 + .map((row) => { 1283 + const actor = this.queryProfileView(row.srcdid, ""); 1284 + if (!actor) return; 1285 + 1286 + return { 1287 + // TODO write indexedAt for spacedust indexes 1288 + createdAt: new Date(Date.now()).toISOString(), 1289 + indexedAt: new Date(Date.now()).toISOString(), 1290 + actor: actor, 1291 + }; 1292 + }) 1293 + .filter((like): like is ATPAPI.AppBskyFeedGetLikes.Like => !!like); 1294 + } 1295 + 1296 + queryReposts(uri: string): ATPAPI.AppBskyActorDefs.ProfileView[] { 1297 + const postUri = new AtUri(uri); 1298 + const postAuthorDid = postUri.hostname; 1299 + if (!this.isRegisteredIndexUser(postAuthorDid)) return []; 1300 + const db = this.userManager.getDbForDid(postAuthorDid); 1301 + if (!db) return []; 1302 + 1303 + const stmt = db.prepare(` 1304 + SELECT srcdid 1305 + FROM backlink_skeleton 1306 + WHERE suburi = ? AND srccol = 'app_bsky_feed_repost' 1307 + ORDER BY id DESC; 1308 + `); 1309 + 1310 + const rows = stmt.all(uri) as { srcdid: string }[]; 1311 + 1312 + return rows 1313 + .map((row) => this.queryProfileView(row.srcdid, "")) 1314 + .filter((p): p is ATPAPI.AppBskyActorDefs.ProfileView => !!p); 1315 + } 1316 + 1317 + queryQuotes(uri: string): ATPAPI.AppBskyFeedDefs.FeedViewPost[] { 1318 + const postUri = new AtUri(uri); 1319 + const postAuthorDid = postUri.hostname; 1320 + if (!this.isRegisteredIndexUser(postAuthorDid)) return []; 1321 + const db = this.userManager.getDbForDid(postAuthorDid); 1322 + if (!db) return []; 1323 + 1324 + const stmt = db.prepare(` 1325 + SELECT srcuri 1326 + FROM backlink_skeleton 1327 + WHERE suburi = ? AND srccol = 'app_bsky_feed_post' AND srcfield = 'quote' 1328 + ORDER BY id DESC; 1329 + `); 1330 + 1331 + const rows = stmt.all(uri) as { srcuri: string }[]; 1332 + 1333 + return rows 1334 + .map((row) => this.queryFeedViewPost(row.srcuri)) 1335 + .filter((p): p is ATPAPI.AppBskyFeedDefs.FeedViewPost => !!p); 1336 + } 1337 + 1338 + queryPostThread( 1339 + uri: string 1340 + ): ATPAPI.AppBskyFeedGetPostThread.OutputSchema | undefined { 1341 + const post = this.queryPostView(uri); 1342 + if (!post) { 1343 + return { 1344 + thread: { 1345 + $type: "app.bsky.feed.defs#notFoundPost", 1346 + uri: uri, 1347 + notFound: true, 1348 + } as ATPAPI.$Typed<ATPAPI.AppBskyFeedDefs.NotFoundPost>, 1349 + }; 1350 + } 1351 + 1352 + const thread: ATPAPI.AppBskyFeedDefs.ThreadViewPost = { 1353 + $type: "app.bsky.feed.defs#threadViewPost", 1354 + post: post, 1355 + replies: [], 1356 + }; 1357 + 1358 + let current = thread; 1359 + while ((current.post.record.reply as any)?.parent?.uri) { 1360 + const parentUri = (current.post.record.reply as any)?.parent?.uri; 1361 + const parentPost = this.queryPostView(parentUri); 1362 + if (!parentPost) break; 1363 + 1364 + const parentThread: ATPAPI.AppBskyFeedDefs.ThreadViewPost = { 1365 + $type: "app.bsky.feed.defs#threadViewPost", 1366 + post: parentPost, 1367 + replies: [ 1368 + current as ATPAPI.$Typed<ATPAPI.AppBskyFeedDefs.ThreadViewPost>, 1369 + ], 1370 + }; 1371 + current.parent = 1372 + parentThread as ATPAPI.$Typed<ATPAPI.AppBskyFeedDefs.ThreadViewPost>; 1373 + current = parentThread; 1374 + } 1375 + 1376 + const seenUris = new Set<string>(); 1377 + const fetchReplies = ( 1378 + parentThread: ATPAPI.AppBskyFeedDefs.ThreadViewPost 1379 + ) => { 1380 + if (seenUris.has(parentThread.post.uri)) return; 1381 + seenUris.add(parentThread.post.uri); 1382 + 1383 + const parentUri = new AtUri(parentThread.post.uri); 1384 + const parentAuthorDid = parentUri.hostname; 1385 + const db = this.userManager.getDbForDid(parentAuthorDid); 1386 + if (!db) return; 1387 + 1388 + const stmt = db.prepare(` 1389 + SELECT srcuri 1390 + FROM backlink_skeleton 1391 + WHERE suburi = ? AND srccol = 'app_bsky_feed_post' AND srcfield = 'replyparent' 1392 + `); 1393 + const replyRows = stmt.all(parentThread.post.uri) as { srcuri: string }[]; 1394 + 1395 + const replies = replyRows 1396 + .map((row) => this.queryPostView(row.srcuri)) 1397 + .filter((p): p is ATPAPI.AppBskyFeedDefs.PostView => !!p); 1398 + 1399 + for (const replyPost of replies) { 1400 + const replyThread: ATPAPI.AppBskyFeedDefs.ThreadViewPost = { 1401 + $type: "app.bsky.feed.defs#threadViewPost", 1402 + post: replyPost, 1403 + parent: 1404 + parentThread as ATPAPI.$Typed<ATPAPI.AppBskyFeedDefs.ThreadViewPost>, 1405 + replies: [], 1406 + }; 1407 + parentThread.replies?.push( 1408 + replyThread as ATPAPI.$Typed<ATPAPI.AppBskyFeedDefs.ThreadViewPost> 1409 + ); 1410 + fetchReplies(replyThread); 1411 + } 1412 + }; 1413 + 1414 + fetchReplies(thread); 1415 + 1416 + const returned = 1417 + thread as ATPAPI.$Typed<ATPAPI.AppBskyFeedDefs.ThreadViewPost>; 1418 + 1419 + return { thread: returned }; 1420 + } 1421 + 1422 + /** 1423 + * please do not use this, use openDbForDid() instead 1424 + * @param did 1425 + * @returns 1426 + */ 1427 + internalCreateDbForDid(did: string): Database { 1428 + const path = `${this.config.baseDbPath}/${did}.sqlite`; 1429 + const db = new Database(path); 1430 + setupUserDb(db); 1431 + //await db.exec(/* CREATE IF NOT EXISTS statements */); 1432 + return db; 1433 + } 1434 + 1435 + isRegisteredIndexUser(did: string): boolean { 1436 + const stmt = this.systemDB.prepare(` 1437 + SELECT 1 1438 + FROM users 1439 + WHERE did = ? 1440 + AND onboardingstatus != 'onboarding-backfill' 1441 + LIMIT 1; 1442 + `); 1443 + const result = stmt.value<[number]>(did); 1444 + const exists = result !== undefined; 1445 + return exists; 1446 + } 1447 + } 1448 + 16 1449 export class IndexServerUserManager { 1450 + public indexServer: IndexServer; 1451 + 1452 + constructor(indexServer: IndexServer) { 1453 + this.indexServer = indexServer; 1454 + } 1455 + 17 1456 private users = new Map<string, UserIndexServer>(); 18 1457 19 1458 /*async*/ addUser(did: string) { 20 1459 if (this.users.has(did)) return; 21 - const instance = new UserIndexServer(did); 1460 + const instance = new UserIndexServer(this, did); 22 1461 //await instance.initialize(); 23 1462 this.users.set(did, instance); 24 1463 } ··· 60 1499 } 61 1500 62 1501 class UserIndexServer { 1502 + public indexServerUserManager: IndexServerUserManager; 63 1503 did: string; 64 1504 db: Database; // | undefined; 65 1505 jetstream: JetstreamManager; // | undefined; 66 1506 spacedust: SpacedustManager; // | undefined; 67 1507 68 - constructor(did: string) { 1508 + constructor(indexServerUserManager: IndexServerUserManager, did: string) { 69 1509 this.did = did; 70 - this.db = internalCreateDbForDid(this.did); 1510 + this.indexServerUserManager = indexServerUserManager; 1511 + this.db = this.indexServerUserManager.indexServer.internalCreateDbForDid(this.did); 71 1512 // should probably put the params of exactly what were listening to here 72 1513 this.jetstream = new JetstreamManager((msg) => { 73 1514 console.log("Received Jetstream message: ", msg); ··· 79 1520 const value = msg.commit.record; 80 1521 81 1522 if (!doer || !value) return; 82 - indexServerIndexer({ 1523 + this.indexServerUserManager.indexServer.indexServerIndexer({ 83 1524 op, 84 1525 doer, 85 1526 cid: msg.commit.cid, ··· 227 1668 } 228 1669 } 229 1670 230 - function isRegisteredIndexUser(did: string): boolean { 231 - const stmt = systemDB.prepare(` 232 - SELECT 1 233 - FROM users 234 - WHERE did = ? 235 - AND onboardingstatus != 'onboarding-backfill' 236 - LIMIT 1; 237 - `); 238 - const result = stmt.value<[number]>(did); 239 - const exists = result !== undefined; 240 - return exists; 241 - } 242 - 243 - /** 244 - * please do not use this, use openDbForDid() instead 245 - * @param did 246 - * @returns 247 - */ 248 - function internalCreateDbForDid(did: string): Database { 249 - const path = `./dbs/${did}.sqlite`; 250 - const db = new Database(path); 251 - setupUserDb(db); 252 - //await db.exec(/* CREATE IF NOT EXISTS statements */); 253 - return db; 254 - } 1671 + // /** 1672 + // * please do not use this, use openDbForDid() instead 1673 + // * @param did 1674 + // * @returns 1675 + // */ 1676 + // function internalCreateDbForDid(did: string): Database { 1677 + // const path = `./dbs/${did}.sqlite`; 1678 + // const db = new Database(path); 1679 + // setupUserDb(db); 1680 + // //await db.exec(/* CREATE IF NOT EXISTS statements */); 1681 + // return db; 1682 + // } 255 1683 256 - function getDbForDid(did: string): Database | undefined { 257 - const db = indexerUserManager.getDbForDid(did); 258 - if (!db) return; 259 - return db; 260 - } 1684 + // function getDbForDid(did: string): Database | undefined { 1685 + // const db = indexerUserManager.getDbForDid(did); 1686 + // if (!db) return; 1687 + // return db; 1688 + // } 261 1689 262 1690 // async function connectToJetstream(did: string, db: Database): Promise<WebSocket> { 263 1691 // const url = `${jetstreamurl}/xrpc/com.atproto.sync.subscribeRepos?did=${did}`; ··· 342 1770 bannermime: string | null; 343 1771 }; 344 1772 345 - export async function indexServerHandler(req: Request): Promise<Response> { 346 - const url = new URL(req.url); 347 - const pathname = url.pathname; 348 - //const bskyUrl = `https://api.bsky.app${pathname}${url.search}`; 349 - //const hasAuth = req.headers.has("authorization"); 350 - const xrpcMethod = pathname.startsWith("/xrpc/") 351 - ? pathname.slice("/xrpc/".length) 352 - : null; 353 - const searchParams = searchParamsToJson(url.searchParams); 354 - console.log(JSON.stringify(searchParams, null, 2)); 355 - const jsonUntyped = searchParams; 356 - 357 - switch (xrpcMethod) { 358 - case "app.bsky.actor.getProfile": { 359 - const jsonTyped = 360 - jsonUntyped as IndexServerTypes.AppBskyActorGetProfile.QueryParams; 361 - 362 - const res = queryProfileView(jsonTyped.actor, "Detailed"); 363 - if (!res) 364 - return new Response( 365 - JSON.stringify({ 366 - error: "User not found", 367 - }), 368 - { 369 - status: 404, 370 - headers: withCors({ "Content-Type": "application/json" }), 371 - } 372 - ); 373 - const response: IndexServerTypes.AppBskyActorGetProfile.OutputSchema = 374 - res; 375 - 376 - return new Response(JSON.stringify(response), { 377 - headers: withCors({ "Content-Type": "application/json" }), 378 - }); 379 - } 380 - case "app.bsky.actor.getProfiles": { 381 - const jsonTyped = 382 - jsonUntyped as IndexServerTypes.AppBskyActorGetProfiles.QueryParams; 383 - 384 - if (typeof jsonUntyped?.actors === "string" ) { 385 - const res = queryProfileView(jsonUntyped.actors as string, "Detailed"); 386 - if (!res) 387 - return new Response( 388 - JSON.stringify({ 389 - error: "User not found", 390 - }), 391 - { 392 - status: 404, 393 - headers: withCors({ "Content-Type": "application/json" }), 394 - } 395 - ); 396 - const response: IndexServerTypes.AppBskyActorGetProfiles.OutputSchema = { 397 - profiles: [res], 398 - }; 399 - 400 - return new Response(JSON.stringify(response), { 401 - headers: withCors({ "Content-Type": "application/json" }), 402 - }); 403 - } 404 - 405 - const res: ATPAPI.AppBskyActorDefs.ProfileViewDetailed[] = 406 - jsonTyped.actors 407 - .map((actor) => { 408 - return queryProfileView(actor, "Detailed"); 409 - }) 410 - .filter( 411 - (x): x is ATPAPI.AppBskyActorDefs.ProfileViewDetailed => 412 - x !== undefined 413 - ); 414 - 415 - if (!res) 416 - return new Response( 417 - JSON.stringify({ 418 - error: "User not found", 419 - }), 420 - { 421 - status: 404, 422 - headers: withCors({ "Content-Type": "application/json" }), 423 - } 424 - ); 425 - 426 - const response: IndexServerTypes.AppBskyActorGetProfiles.OutputSchema = { 427 - profiles: res, 428 - }; 429 - 430 - return new Response(JSON.stringify(response), { 431 - headers: withCors({ "Content-Type": "application/json" }), 432 - }); 433 - } 434 - case "app.bsky.feed.getActorFeeds": { 435 - const jsonTyped = 436 - jsonUntyped as IndexServerTypes.AppBskyFeedGetActorFeeds.QueryParams; 437 - 438 - const qresult = queryActorFeeds(jsonTyped.actor) 439 - 440 - const response: IndexServerTypes.AppBskyFeedGetActorFeeds.OutputSchema = 441 - { 442 - feeds: qresult 443 - }; 444 - 445 - return new Response(JSON.stringify(response), { 446 - headers: withCors({ "Content-Type": "application/json" }), 447 - }); 448 - } 449 - case "app.bsky.feed.getFeedGenerator": { 450 - const jsonTyped = 451 - jsonUntyped as IndexServerTypes.AppBskyFeedGetFeedGenerator.QueryParams; 452 - 453 - const qresult = queryFeedGenerator(jsonTyped.feed) 454 - if (!qresult) { 455 - return new Response( 456 - JSON.stringify({ 457 - error: "Feed not found", 458 - }), 459 - { 460 - status: 404, 461 - headers: withCors({ "Content-Type": "application/json" }), 462 - } 463 - ); 464 - } 465 - 466 - const response: IndexServerTypes.AppBskyFeedGetFeedGenerator.OutputSchema = 467 - { 468 - view: qresult, 469 - isOnline: true, // lmao 470 - isValid: true, // lmao 471 - }; 472 - 473 - return new Response(JSON.stringify(response), { 474 - headers: withCors({ "Content-Type": "application/json" }), 475 - }); 476 - } 477 - case "app.bsky.feed.getFeedGenerators": { 478 - const jsonTyped = 479 - jsonUntyped as IndexServerTypes.AppBskyFeedGetFeedGenerators.QueryParams; 480 - 481 - const qresult = queryFeedGenerators(jsonTyped.feeds) 482 - if (!qresult) { 483 - return new Response( 484 - JSON.stringify({ 485 - error: "Feed not found", 486 - }), 487 - { 488 - status: 404, 489 - headers: withCors({ "Content-Type": "application/json" }), 490 - } 491 - ); 492 - } 493 - 494 - const response: IndexServerTypes.AppBskyFeedGetFeedGenerators.OutputSchema = 495 - { 496 - feeds: qresult 497 - }; 498 - 499 - return new Response(JSON.stringify(response), { 500 - headers: withCors({ "Content-Type": "application/json" }), 501 - }); 502 - } 503 - case "app.bsky.feed.getPosts": { 504 - const jsonTyped = 505 - jsonUntyped as IndexServerTypes.AppBskyFeedGetPosts.QueryParams; 506 - 507 - const posts: IndexServerTypes.AppBskyFeedGetPosts.OutputSchema["posts"] = 508 - jsonTyped.uris 509 - .map((uri) => { 510 - return queryPostView(uri); 511 - }) 512 - .filter(Boolean) as ATPAPI.AppBskyFeedDefs.PostView[]; 513 - 514 - const response: IndexServerTypes.AppBskyFeedGetPosts.OutputSchema = { 515 - posts, 516 - }; 517 - 518 - return new Response(JSON.stringify(response), { 519 - headers: withCors({ "Content-Type": "application/json" }), 520 - }); 521 - } 522 - case "party.whey.app.bsky.feed.getActorLikesPartial": { 523 - const jsonTyped = 524 - jsonUntyped as IndexServerTypes.PartyWheyAppBskyFeedGetActorLikesPartial.QueryParams; 525 - 526 - // TODO: not partial yet, currently skips refs 527 - 528 - const qresult = queryActorLikes(jsonTyped.actor, jsonTyped.cursor) 529 - if (!qresult) { 530 - return new Response( 531 - JSON.stringify({ 532 - error: "Feed not found", 533 - }), 534 - { 535 - status: 404, 536 - headers: withCors({ "Content-Type": "application/json" }), 537 - } 538 - ); 539 - } 540 - 541 - const response: IndexServerTypes.PartyWheyAppBskyFeedGetActorLikesPartial.OutputSchema = 542 - { 543 - feed: qresult.items as ATPAPI.$Typed<ATPAPI.AppBskyFeedDefs.FeedViewPost>[], 544 - cursor: qresult.cursor 545 - }; 546 - 547 - return new Response(JSON.stringify(response), { 548 - headers: withCors({ "Content-Type": "application/json" }), 549 - }); 550 - } 551 - case "party.whey.app.bsky.feed.getAuthorFeedPartial": { 552 - const jsonTyped = 553 - jsonUntyped as IndexServerTypes.PartyWheyAppBskyFeedGetAuthorFeedPartial.QueryParams; 554 - 555 - // TODO: not partial yet, currently skips refs 556 - 557 - const qresult = queryAuthorFeed(jsonTyped.actor, jsonTyped.cursor) 558 - if (!qresult) { 559 - return new Response( 560 - JSON.stringify({ 561 - error: "Feed not found", 562 - }), 563 - { 564 - status: 404, 565 - headers: withCors({ "Content-Type": "application/json" }), 566 - } 567 - ); 568 - } 569 - 570 - const response: IndexServerTypes.PartyWheyAppBskyFeedGetAuthorFeedPartial.OutputSchema = 571 - { 572 - feed: qresult.items as ATPAPI.$Typed<ATPAPI.AppBskyFeedDefs.FeedViewPost>[], 573 - cursor: qresult.cursor 574 - }; 575 - 576 - return new Response(JSON.stringify(response), { 577 - headers: withCors({ "Content-Type": "application/json" }), 578 - }); 579 - } 580 - case "party.whey.app.bsky.feed.getLikesPartial": { 581 - const jsonTyped = 582 - jsonUntyped as IndexServerTypes.PartyWheyAppBskyFeedGetLikesPartial.QueryParams; 583 - 584 - // TODO: not partial yet, currently skips refs 585 - 586 - const qresult = queryLikes(jsonTyped.uri) 587 - if (!qresult) { 588 - return new Response( 589 - JSON.stringify({ 590 - error: "Feed not found", 591 - }), 592 - { 593 - status: 404, 594 - headers: withCors({ "Content-Type": "application/json" }), 595 - } 596 - ); 597 - } 598 - const response: IndexServerTypes.PartyWheyAppBskyFeedGetLikesPartial.OutputSchema = 599 - { 600 - // @ts-ignore whatever i dont care TODO: fix ts ignores 601 - likes: qresult 602 - }; 603 - 604 - return new Response(JSON.stringify(response), { 605 - headers: withCors({ "Content-Type": "application/json" }), 606 - }); 607 - } 608 - case "party.whey.app.bsky.feed.getPostThreadPartial": { 609 - const jsonTyped = 610 - jsonUntyped as IndexServerTypes.PartyWheyAppBskyFeedGetPostThreadPartial.QueryParams; 611 - 612 - // TODO: not partial yet, currently skips refs 613 - 614 - const qresult = queryPostThread(jsonTyped.uri) 615 - if (!qresult) { 616 - return new Response( 617 - JSON.stringify({ 618 - error: "Feed not found", 619 - }), 620 - { 621 - status: 404, 622 - headers: withCors({ "Content-Type": "application/json" }), 623 - } 624 - ); 625 - } 626 - const response: IndexServerTypes.PartyWheyAppBskyFeedGetPostThreadPartial.OutputSchema = 627 - qresult 628 - 629 - return new Response(JSON.stringify(response), { 630 - headers: withCors({ "Content-Type": "application/json" }), 631 - }); 632 - } 633 - case "party.whey.app.bsky.feed.getQuotesPartial": { 634 - const jsonTyped = 635 - jsonUntyped as IndexServerTypes.PartyWheyAppBskyFeedGetQuotesPartial.QueryParams; 636 - 637 - // TODO: not partial yet, currently skips refs 638 - 639 - const qresult = queryQuotes(jsonTyped.uri) 640 - if (!qresult) { 641 - return new Response( 642 - JSON.stringify({ 643 - error: "Feed not found", 644 - }), 645 - { 646 - status: 404, 647 - headers: withCors({ "Content-Type": "application/json" }), 648 - } 649 - ); 650 - } 651 - const response: IndexServerTypes.PartyWheyAppBskyFeedGetQuotesPartial.OutputSchema = 652 - { 653 - uri: jsonTyped.uri, 654 - posts: qresult.map((feedviewpost)=>{return feedviewpost.post as ATPAPI.$Typed<ATPAPI.AppBskyFeedDefs.PostView>}) 655 - }; 656 - 657 - return new Response(JSON.stringify(response), { 658 - headers: withCors({ "Content-Type": "application/json" }), 659 - }); 660 - } 661 - case "party.whey.app.bsky.feed.getRepostedByPartial": { 662 - const jsonTyped = 663 - jsonUntyped as IndexServerTypes.PartyWheyAppBskyFeedGetRepostedByPartial.QueryParams; 664 - 665 - // TODO: not partial yet, currently skips refs 666 - 667 - const qresult = queryReposts(jsonTyped.uri) 668 - if (!qresult) { 669 - return new Response( 670 - JSON.stringify({ 671 - error: "Feed not found", 672 - }), 673 - { 674 - status: 404, 675 - headers: withCors({ "Content-Type": "application/json" }), 676 - } 677 - ); 678 - } 679 - const response: IndexServerTypes.PartyWheyAppBskyFeedGetRepostedByPartial.OutputSchema = 680 - { 681 - uri: jsonTyped.uri, 682 - repostedBy: qresult as ATPAPI.$Typed<ATPAPI.AppBskyActorDefs.ProfileView>[] 683 - }; 684 - 685 - return new Response(JSON.stringify(response), { 686 - headers: withCors({ "Content-Type": "application/json" }), 687 - }); 688 - } 689 - // TODO: too hard for now 690 - // case "party.whey.app.bsky.feed.getListFeedPartial": { 691 - // const jsonTyped = 692 - // jsonUntyped as IndexServerTypes.PartyWheyAppBskyFeedGetListFeedPartial.QueryParams; 693 - 694 - // const response: IndexServerTypes.PartyWheyAppBskyFeedGetListFeedPartial.OutputSchema = 695 - // {}; 696 - 697 - // return new Response(JSON.stringify(response), { 698 - // headers: withCors({ "Content-Type": "application/json" }), 699 - // }); 700 - // } 701 - /* three more coming soon 702 - app.bsky.graph.getLists 703 - app.bsky.graph.getList 704 - app.bsky.graph.getActorStarterPacks 705 - */ 706 - default: { 707 - return new Response( 708 - JSON.stringify({ 709 - error: "XRPCNotSupported", 710 - message: 711 - "HEY hello there my name is whey dot party and you have used my custom appview that is very cool but have you considered that XRPC Not Supported", 712 - }), 713 - { 714 - status: 404, 715 - headers: withCors({ "Content-Type": "application/json" }), 716 - } 717 - ); 718 - } 719 - } 720 - 721 - // return new Response("Not Found", { status: 404 }); 722 - } 723 - 724 1773 type linksQuery = { 725 1774 target: string; 726 1775 collection: string; ··· 795 1844 return typeof str === "string" && str.startsWith("did:"); 796 1845 } 797 1846 798 - export async function constellationAPIHandler(req: Request): Promise<Response> { 799 - const url = new URL(req.url); 800 - const pathname = url.pathname; 801 - const searchParams = searchParamsToJson(url.searchParams) as linksQuery; 802 - const jsonUntyped = searchParams; 803 - 804 - if (!jsonUntyped.target) { 805 - return new Response( 806 - JSON.stringify({ error: "Missing required parameter: target" }), 807 - { 808 - status: 400, 809 - headers: withCors({ "Content-Type": "application/json" }), 810 - } 811 - ); 812 - } 813 - 814 - const did = isDid(searchParams.target) 815 - ? searchParams.target 816 - : new AtUri(searchParams.target).host; 817 - const db = getDbForDid(did); 818 - if (!db) { 819 - return new Response( 820 - JSON.stringify({ 821 - error: "User not found", 822 - }), 823 - { 824 - status: 404, 825 - headers: withCors({ "Content-Type": "application/json" }), 826 - } 827 - ); 828 - } 829 - 830 - const limit = 16; //Math.min(parseInt(searchParams.limit || "50", 10), 100); 831 - const offset = parseInt(searchParams.cursor || "0", 10); 832 - 833 - switch (pathname) { 834 - case "/links": { 835 - const jsonTyped = jsonUntyped as linksQuery; 836 - if (!jsonTyped.collection || !jsonTyped.path) { 837 - return new Response( 838 - JSON.stringify({ 839 - error: "Missing required parameters: collection, path", 840 - }), 841 - { 842 - status: 400, 843 - headers: withCors({ "Content-Type": "application/json" }), 844 - } 845 - ); 846 - } 847 - 848 - const field = `${jsonTyped.collection}:${jsonTyped.path.replace( 849 - /^\./, 850 - "" 851 - )}`; 852 - 853 - const paginatedSql = `${SQL.links} LIMIT ? OFFSET ?`; 854 - const rows = db 855 - .prepare(paginatedSql) 856 - .all(jsonTyped.target, jsonTyped.collection, field, limit, offset); 857 - 858 - const countResult = db 859 - .prepare(SQL.count) 860 - .get(jsonTyped.target, jsonTyped.collection, field); 861 - const total = countResult ? Number(countResult.total) : 0; 862 - 863 - const linking_records: linksRecord[] = rows.map((row: any) => { 864 - const rkey = row.srcuri.split("/").pop()!; 865 - return { 866 - did: row.srcdid, 867 - collection: row.srccol, 868 - rkey, 869 - }; 870 - }); 871 - 872 - const response: linksRecordsResponse = { 873 - total: total.toString(), 874 - linking_records, 875 - }; 876 - 877 - const nextCursor = offset + linking_records.length; 878 - if (nextCursor < total) { 879 - response.cursor = nextCursor.toString(); 880 - } 881 - 882 - return new Response(JSON.stringify(response), { 883 - headers: withCors({ "Content-Type": "application/json" }), 884 - }); 885 - } 886 - case "/links/distinct-dids": { 887 - const jsonTyped = jsonUntyped as linksQuery; 888 - if (!jsonTyped.collection || !jsonTyped.path) { 889 - return new Response( 890 - JSON.stringify({ 891 - error: "Missing required parameters: collection, path", 892 - }), 893 - { 894 - status: 400, 895 - headers: withCors({ "Content-Type": "application/json" }), 896 - } 897 - ); 898 - } 899 - 900 - const field = `${jsonTyped.collection}:${jsonTyped.path.replace( 901 - /^\./, 902 - "" 903 - )}`; 904 - 905 - const paginatedSql = `${SQL.distinctDids} LIMIT ? OFFSET ?`; 906 - const rows = db 907 - .prepare(paginatedSql) 908 - .all(jsonTyped.target, jsonTyped.collection, field, limit, offset); 909 - 910 - const countResult = db 911 - .prepare(SQL.countDistinctDids) 912 - .get(jsonTyped.target, jsonTyped.collection, field); 913 - const total = countResult ? Number(countResult.total) : 0; 914 - 915 - const linking_dids: string[] = rows.map((row: any) => row.srcdid); 916 - 917 - const response: linksDidsResponse = { 918 - total: total.toString(), 919 - linking_dids, 920 - }; 921 - 922 - const nextCursor = offset + linking_dids.length; 923 - if (nextCursor < total) { 924 - response.cursor = nextCursor.toString(); 925 - } 926 - 927 - return new Response(JSON.stringify(response), { 928 - headers: withCors({ "Content-Type": "application/json" }), 929 - }); 930 - } 931 - case "/links/count": { 932 - const jsonTyped = jsonUntyped as linksQuery; 933 - if (!jsonTyped.collection || !jsonTyped.path) { 934 - return new Response( 935 - JSON.stringify({ 936 - error: "Missing required parameters: collection, path", 937 - }), 938 - { 939 - status: 400, 940 - headers: withCors({ "Content-Type": "application/json" }), 941 - } 942 - ); 943 - } 944 - 945 - const field = `${jsonTyped.collection}:${jsonTyped.path.replace( 946 - /^\./, 947 - "" 948 - )}`; 949 - 950 - const result = db 951 - .prepare(SQL.count) 952 - .get(jsonTyped.target, jsonTyped.collection, field); 953 - 954 - const response: linksCountResponse = { 955 - total: result && result.total ? result.total.toString() : "0", 956 - }; 957 - 958 - return new Response(JSON.stringify(response), { 959 - headers: withCors({ "Content-Type": "application/json" }), 960 - }); 961 - } 962 - case "/links/count/distinct-dids": { 963 - const jsonTyped = jsonUntyped as linksQuery; 964 - if (!jsonTyped.collection || !jsonTyped.path) { 965 - return new Response( 966 - JSON.stringify({ 967 - error: "Missing required parameters: collection, path", 968 - }), 969 - { 970 - status: 400, 971 - headers: withCors({ "Content-Type": "application/json" }), 972 - } 973 - ); 974 - } 975 - 976 - const field = `${jsonTyped.collection}:${jsonTyped.path.replace( 977 - /^\./, 978 - "" 979 - )}`; 980 - 981 - const result = db 982 - .prepare(SQL.countDistinctDids) 983 - .get(jsonTyped.target, jsonTyped.collection, field); 984 - 985 - const response: linksCountResponse = { 986 - total: result && result.total ? result.total.toString() : "0", 987 - }; 988 - 989 - return new Response(JSON.stringify(response), { 990 - headers: withCors({ "Content-Type": "application/json" }), 991 - }); 992 - } 993 - case "/links/all": { 994 - const jsonTyped = jsonUntyped as linksAllQuery; 995 - 996 - const rows = db.prepare(SQL.all).all(jsonTyped.target) as any[]; 997 - 998 - const links: linksAllResponse["links"] = {}; 999 - 1000 - for (const row of rows) { 1001 - if (!links[row.suburi]) { 1002 - links[row.suburi] = {}; 1003 - } 1004 - links[row.suburi][row.srccol] = { 1005 - records: row.records, 1006 - distinct_dids: row.distinct_dids, 1007 - }; 1008 - } 1009 - 1010 - const response: linksAllResponse = { 1011 - links, 1012 - }; 1013 - 1014 - return new Response(JSON.stringify(response), { 1015 - headers: withCors({ "Content-Type": "application/json" }), 1016 - }); 1017 - } 1018 - default: { 1019 - return new Response( 1020 - JSON.stringify({ 1021 - error: "NotSupported", 1022 - message: 1023 - "The requested endpoint is not supported by this Constellation implementation.", 1024 - }), 1025 - { 1026 - status: 404, 1027 - headers: withCors({ "Content-Type": "application/json" }), 1028 - } 1029 - ); 1030 - } 1031 - } 1032 - } 1033 - 1034 1847 function isImageEmbed(embed: unknown): embed is ATPAPI.AppBskyEmbedImages.Main { 1035 1848 return ( 1036 1849 typeof embed === "object" && ··· 1096 1909 if (isRecordWithMediaEmbed(embed)) return embed.record.record.uri; 1097 1910 return null; 1098 1911 } 1099 - 1100 - export function indexServerIndexer(ctx: indexHandlerContext) { 1101 - const record = assertRecord(ctx.value); 1102 - //const record = validateRecord(ctx.value); 1103 - const db = getDbForDid(ctx.doer); 1104 - if (!db) return; 1105 - console.log("indexering"); 1106 - switch (record?.$type) { 1107 - case "app.bsky.feed.like": { 1108 - return; 1109 - } 1110 - case "app.bsky.actor.profile": { 1111 - console.log("bsky profuile"); 1112 - 1113 - try { 1114 - const stmt = db.prepare(` 1115 - INSERT OR IGNORE INTO app_bsky_actor_profile ( 1116 - uri, did, cid, rev, createdat, indexedat, json, 1117 - displayname, 1118 - description, 1119 - avatarcid, 1120 - avatarmime, 1121 - bannercid, 1122 - bannermime 1123 - ) VALUES (?, ?, ?, ?, ?, ?, ?, 1124 - ?, ?, ?, 1125 - ?, ?, ?) 1126 - `); 1127 - console.log({ 1128 - uri: ctx.aturi, 1129 - did: ctx.doer, 1130 - cid: ctx.cid, 1131 - rev: ctx.rev, 1132 - createdat: record.createdAt, 1133 - indexedat: Date.now(), 1134 - json: JSON.stringify(record), 1135 - displayname: record.displayName, 1136 - description: record.description, 1137 - avatarcid: uncid(record.avatar?.ref), 1138 - avatarmime: record.avatar?.mimeType, 1139 - bannercid: uncid(record.banner?.ref), 1140 - bannermime: record.banner?.mimeType, 1141 - }); 1142 - stmt.run( 1143 - ctx.aturi ?? null, 1144 - ctx.doer ?? null, 1145 - ctx.cid ?? null, 1146 - ctx.rev ?? null, 1147 - record.createdAt ?? null, 1148 - Date.now(), 1149 - JSON.stringify(record), 1150 - 1151 - record.displayName ?? null, 1152 - record.description ?? null, 1153 - uncid(record.avatar?.ref) ?? null, 1154 - record.avatar?.mimeType ?? null, 1155 - uncid(record.banner?.ref) ?? null, 1156 - record.banner?.mimeType ?? null, 1157 - // TODO please add pinned posts 1158 - 1159 - ); 1160 - } catch (err) { 1161 - console.error("stmt.run failed:", err); 1162 - } 1163 - return; 1164 - } 1165 - case "app.bsky.feed.post": { 1166 - console.log("bsky post"); 1167 - const stmt = db.prepare(` 1168 - INSERT OR IGNORE INTO app_bsky_feed_post ( 1169 - uri, did, cid, rev, createdat, indexedat, json, 1170 - text, replyroot, replyparent, quote, 1171 - imagecount, image1cid, image1mime, image1aspect, 1172 - image2cid, image2mime, image2aspect, 1173 - image3cid, image3mime, image3aspect, 1174 - image4cid, image4mime, image4aspect, 1175 - videocount, videocid, videomime, videoaspect 1176 - ) VALUES (?, ?, ?, ?, ?, ?, ?, 1177 - ?, ?, ?, ?, 1178 - ?, ?, ?, ?, 1179 - ?, ?, ?, 1180 - ?, ?, ?, 1181 - ?, ?, ?, 1182 - ?, ?, ?, ?) 1183 - `); 1184 - 1185 - const embed = record.embed; 1186 - 1187 - const images = extractImages(embed); 1188 - const video = extractVideo(embed); 1189 - const quoteUri = extractQuoteUri(embed); 1190 - try { 1191 - stmt.run( 1192 - ctx.aturi ?? null, 1193 - ctx.doer ?? null, 1194 - ctx.cid ?? null, 1195 - ctx.rev ?? null, 1196 - record.createdAt, 1197 - Date.now(), 1198 - JSON.stringify(record), 1199 - 1200 - record.text ?? null, 1201 - record.reply?.root?.uri ?? null, 1202 - record.reply?.parent?.uri ?? null, 1203 - 1204 - quoteUri, 1205 - 1206 - images.length, 1207 - uncid(images[0]?.image?.ref) ?? null, 1208 - images[0]?.image?.mimeType ?? null, 1209 - images[0]?.aspectRatio && 1210 - images[0].aspectRatio.width && 1211 - images[0].aspectRatio.height 1212 - ? `${images[0].aspectRatio.width}:${images[0].aspectRatio.height}` 1213 - : null, 1214 - 1215 - uncid(images[1]?.image?.ref) ?? null, 1216 - images[1]?.image?.mimeType ?? null, 1217 - images[1]?.aspectRatio && 1218 - images[1].aspectRatio.width && 1219 - images[1].aspectRatio.height 1220 - ? `${images[1].aspectRatio.width}:${images[1].aspectRatio.height}` 1221 - : null, 1222 - 1223 - uncid(images[2]?.image?.ref) ?? null, 1224 - images[2]?.image?.mimeType ?? null, 1225 - images[2]?.aspectRatio && 1226 - images[2].aspectRatio.width && 1227 - images[2].aspectRatio.height 1228 - ? `${images[2].aspectRatio.width}:${images[2].aspectRatio.height}` 1229 - : null, 1230 - 1231 - uncid(images[3]?.image?.ref) ?? null, 1232 - images[3]?.image?.mimeType ?? null, 1233 - images[3]?.aspectRatio && 1234 - images[3].aspectRatio.width && 1235 - images[3].aspectRatio.height 1236 - ? `${images[3].aspectRatio.width}:${images[3].aspectRatio.height}` 1237 - : null, 1238 - 1239 - uncid(video?.video) ? 1 : 0, 1240 - uncid(video?.video) ?? null, 1241 - uncid(video?.video) ? "video/mp4" : null, 1242 - video?.aspectRatio 1243 - ? `${video.aspectRatio.width}:${video.aspectRatio.height}` 1244 - : null 1245 - ); 1246 - } catch (err) { 1247 - console.error("stmt.run failed:", err); 1248 - } 1249 - return; 1250 - } 1251 - default: { 1252 - // what the hell 1253 - return; 1254 - } 1255 - } 1256 - } 1257 - 1258 - // user data 1259 - function queryProfileView( 1260 - did: string, 1261 - type: "" 1262 - ): ATPAPI.AppBskyActorDefs.ProfileView | undefined; 1263 - function queryProfileView( 1264 - did: string, 1265 - type: "Basic" 1266 - ): ATPAPI.AppBskyActorDefs.ProfileViewBasic | undefined; 1267 - function queryProfileView( 1268 - did: string, 1269 - type: "Detailed" 1270 - ): ATPAPI.AppBskyActorDefs.ProfileViewDetailed | undefined; 1271 - function queryProfileView( 1272 - did: string, 1273 - type: "" | "Basic" | "Detailed" 1274 - ): 1275 - | ATPAPI.AppBskyActorDefs.ProfileView 1276 - | ATPAPI.AppBskyActorDefs.ProfileViewBasic 1277 - | ATPAPI.AppBskyActorDefs.ProfileViewDetailed 1278 - | undefined { 1279 - if (!isRegisteredIndexUser(did)) return; 1280 - const db = getDbForDid(did); 1281 - if (!db) return; 1282 - 1283 - const stmt = db.prepare(` 1284 - SELECT * 1285 - FROM app_bsky_actor_profile 1286 - WHERE did = ? 1287 - LIMIT 1; 1288 - `); 1289 - 1290 - const row = stmt.get(did) as ProfileRow; 1291 - 1292 - // simulate different types returned 1293 - switch (type) { 1294 - case "": { 1295 - const result: ATPAPI.AppBskyActorDefs.ProfileView = { 1296 - $type: "app.bsky.actor.defs#profileView", 1297 - did: did, 1298 - handle: "idiot.fuck.shit.example.com", // TODO: Resolve user identity here for the handle 1299 - displayName: row.displayname ?? undefined, 1300 - description: row.description ?? undefined, 1301 - avatar: "https://google.com/", // create profile URL from resolved identity 1302 - //associated?: ProfileAssociated, 1303 - indexedAt: row.createdat 1304 - ? new Date(row.createdat).toISOString() 1305 - : undefined, 1306 - createdAt: row.createdat 1307 - ? new Date(row.createdat).toISOString() 1308 - : undefined, 1309 - //viewer?: ViewerState, 1310 - //labels?: ComAtprotoLabelDefs.Label[], 1311 - //verification?: VerificationState, 1312 - //status?: StatusView, 1313 - }; 1314 - return result; 1315 - } 1316 - case "Basic": { 1317 - const result: ATPAPI.AppBskyActorDefs.ProfileViewBasic = { 1318 - $type: "app.bsky.actor.defs#profileViewBasic", 1319 - did: did, 1320 - handle: "idiot.fuck.shit.example.com", // TODO: Resolve user identity here for the handle 1321 - displayName: row.displayname ?? undefined, 1322 - avatar: "https://google.com/", // create profile URL from resolved identity 1323 - //associated?: ProfileAssociated, 1324 - createdAt: row.createdat 1325 - ? new Date(row.createdat).toISOString() 1326 - : undefined, 1327 - //viewer?: ViewerState, 1328 - //labels?: ComAtprotoLabelDefs.Label[], 1329 - //verification?: VerificationState, 1330 - //status?: StatusView, 1331 - }; 1332 - return result; 1333 - } 1334 - case "Detailed": { 1335 - // Query for follower count from the backlink_skeleton table 1336 - const followersStmt = db.prepare(` 1337 - SELECT COUNT(*) as count 1338 - FROM backlink_skeleton 1339 - WHERE subdid = ? AND srccol = 'app.bsky.graph.follow' 1340 - `); 1341 - const followersResult = followersStmt.get(did) as { count: number }; 1342 - const followersCount = followersResult?.count ?? 0; 1343 - 1344 - // Query for following count from the app_bsky_graph_follow table 1345 - const followingStmt = db.prepare(` 1346 - SELECT COUNT(*) as count 1347 - FROM app_bsky_graph_follow 1348 - WHERE did = ? 1349 - `); 1350 - const followingResult = followingStmt.get(did) as { count: number }; 1351 - const followsCount = followingResult?.count ?? 0; 1352 - 1353 - // Query for post count from the app_bsky_feed_post table 1354 - const postsStmt = db.prepare(` 1355 - SELECT COUNT(*) as count 1356 - FROM app_bsky_feed_post 1357 - WHERE did = ? 1358 - `); 1359 - const postsResult = postsStmt.get(did) as { count: number }; 1360 - const postsCount = postsResult?.count ?? 0; 1361 - 1362 - const result: ATPAPI.AppBskyActorDefs.ProfileViewDetailed = { 1363 - $type: "app.bsky.actor.defs#profileViewDetailed", 1364 - did: did, 1365 - handle: "idiot.fuck.shit.example.com", // TODO: Resolve user identity here for the handle 1366 - displayName: row.displayname ?? undefined, 1367 - description: row.description ?? undefined, 1368 - avatar: "https://google.com/", // TODO: create profile URL from resolved identity 1369 - banner: "https://youtube.com/", // same here 1370 - followersCount: followersCount, 1371 - followsCount: followsCount, 1372 - postsCount: postsCount, 1373 - //associated?: ProfileAssociated, 1374 - //joinedViaStarterPack?: // AppBskyGraphDefs.StarterPackViewBasic; 1375 - indexedAt: row.createdat 1376 - ? new Date(row.createdat).toISOString() 1377 - : undefined, 1378 - createdAt: row.createdat 1379 - ? new Date(row.createdat).toISOString() 1380 - : undefined, 1381 - //viewer?: ViewerState, 1382 - //labels?: ComAtprotoLabelDefs.Label[], 1383 - pinnedPost: undefined, //row.; // TODO: i forgot to put pinnedp posts in db schema oops 1384 - //verification?: VerificationState, 1385 - //status?: StatusView, 1386 - }; 1387 - return result; 1388 - } 1389 - default: 1390 - throw new Error("Invalid type"); 1391 - } 1392 - } 1393 - 1394 - // post hydration 1395 - function queryPostView( 1396 - uri: string 1397 - ): ATPAPI.AppBskyFeedDefs.PostView | undefined { 1398 - const URI = new AtUri(uri); 1399 - const did = URI.host; 1400 - if (!isRegisteredIndexUser(did)) return; 1401 - const db = getDbForDid(did); 1402 - if (!db) return; 1403 - 1404 - const stmt = db.prepare(` 1405 - SELECT * 1406 - FROM app_bsky_feed_post 1407 - WHERE uri = ? 1408 - LIMIT 1; 1409 - `); 1410 - 1411 - const row = stmt.get(uri) as PostRow; 1412 - const profileView = queryProfileView(did, "Basic"); 1413 - if (!row || !row.cid || !profileView || !row.json) return; 1414 - const value = JSON.parse(row.json) as ATPAPI.AppBskyFeedPost.Record; 1415 - 1416 - const post: ATPAPI.AppBskyFeedDefs.PostView = { 1417 - uri: row.uri, 1418 - cid: row.cid, 1419 - author: profileView, 1420 - record: value, 1421 - indexedAt: new Date(row.indexedat).toISOString(), 1422 - embed: value.embed, 1423 - }; 1424 - 1425 - return post; 1426 - } 1427 - function queryFeedViewPost( 1428 - uri: string 1429 - ): ATPAPI.AppBskyFeedDefs.FeedViewPost | undefined { 1430 - 1431 - const post = queryPostView(uri) 1432 - if (!post) return; 1433 - 1434 - const feedviewpost: ATPAPI.AppBskyFeedDefs.FeedViewPost = { 1435 - $type: 'app.bsky.feed.defs#feedViewPost', 1436 - post: post, 1437 - //reply: ReplyRef, 1438 - //reason: , 1439 - }; 1440 - 1441 - return feedviewpost; 1442 - } 1443 - 1444 - interface BaseRow { 1445 - uri: string; 1446 - did: string; 1447 - cid: string | null; 1448 - rev: string | null; 1449 - createdat: number | null; 1450 - indexedat: number; 1451 - json: string | null; 1452 - } 1453 - interface GeneratorRow extends BaseRow { 1454 - displayname: string | null; 1455 - description: string | null; 1456 - avatarcid: string | null; 1457 - } 1458 - interface LikeRow extends BaseRow { 1459 - subject: string; 1460 - } 1461 - interface RepostRow extends BaseRow { 1462 - subject: string; 1463 - } 1464 - interface BacklinkRow { 1465 - srcuri: string; 1466 - srcdid: string; 1467 - } 1468 - 1469 - const FEED_LIMIT = 50; 1470 - 1471 - // user feedgens 1472 - 1473 - function queryActorFeeds(did: string): ATPAPI.AppBskyFeedDefs.GeneratorView[] { 1474 - if (!isRegisteredIndexUser(did)) return []; 1475 - const db = getDbForDid(did); 1476 - if (!db) return []; 1477 - 1478 - const stmt = db.prepare(` 1479 - SELECT uri, cid, did, json, indexedat 1480 - FROM app_bsky_feed_generator 1481 - WHERE did = ? 1482 - ORDER BY createdat DESC; 1483 - `); 1484 - 1485 - const rows = stmt.all(did) as unknown as GeneratorRow[]; 1486 - const creatorView = queryProfileView(did, "Basic"); 1487 - if (!creatorView) return []; 1488 - 1489 - return rows 1490 - .map((row) => { 1491 - try { 1492 - if (!row.json) return; 1493 - const record = JSON.parse( 1494 - row.json 1495 - ) as ATPAPI.AppBskyFeedGenerator.Record; 1496 - return { 1497 - $type: "app.bsky.feed.defs#generatorView", 1498 - uri: row.uri, 1499 - cid: row.cid, 1500 - did: row.did, 1501 - creator: creatorView, 1502 - displayName: record.displayName, 1503 - description: record.description, 1504 - descriptionFacets: record.descriptionFacets, 1505 - avatar: record.avatar, 1506 - likeCount: 0, // TODO: this should be easy 1507 - indexedAt: new Date(row.indexedat).toISOString(), 1508 - } as ATPAPI.AppBskyFeedDefs.GeneratorView; 1509 - } catch { 1510 - return undefined; 1511 - } 1512 - }) 1513 - .filter((v): v is ATPAPI.AppBskyFeedDefs.GeneratorView => !!v); 1514 - } 1515 - 1516 - function queryFeedGenerator( 1517 - uri: string 1518 - ): ATPAPI.AppBskyFeedDefs.GeneratorView | undefined { 1519 - return queryFeedGenerators([uri])[0]; 1520 - } 1521 - 1522 - function queryFeedGenerators( 1523 - uris: string[] 1524 - ): ATPAPI.AppBskyFeedDefs.GeneratorView[] { 1525 - const generators: ATPAPI.AppBskyFeedDefs.GeneratorView[] = []; 1526 - const urisByDid = new Map<string, string[]>(); 1527 - 1528 - for (const uri of uris) { 1529 - try { 1530 - const { host: did } = new AtUri(uri); 1531 - if (!urisByDid.has(did)) { 1532 - urisByDid.set(did, []); 1533 - } 1534 - urisByDid.get(did)!.push(uri); 1535 - } catch { 1536 - } 1537 - } 1538 - 1539 - for (const [did, didUris] of urisByDid.entries()) { 1540 - if (!isRegisteredIndexUser(did)) continue; 1541 - const db = getDbForDid(did); 1542 - if (!db) continue; 1543 - 1544 - const placeholders = didUris.map(() => "?").join(","); 1545 - const stmt = db.prepare(` 1546 - SELECT uri, cid, did, json, indexedat 1547 - FROM app_bsky_feed_generator 1548 - WHERE uri IN (${placeholders}); 1549 - `); 1550 - 1551 - const rows = stmt.all(...didUris) as unknown as GeneratorRow[]; 1552 - if (rows.length === 0) continue; 1553 - 1554 - const creatorView = queryProfileView(did, ""); 1555 - if (!creatorView) continue; 1556 - 1557 - for (const row of rows) { 1558 - try { 1559 - if (!row.json || !row.cid ) continue; 1560 - const record = JSON.parse( 1561 - row.json 1562 - ) as ATPAPI.AppBskyFeedGenerator.Record; 1563 - generators.push({ 1564 - $type: "app.bsky.feed.defs#generatorView", 1565 - uri: row.uri, 1566 - cid: row.cid, 1567 - did: row.did, 1568 - creator: creatorView, 1569 - displayName: record.displayName, 1570 - description: record.description, 1571 - descriptionFacets: record.descriptionFacets, 1572 - avatar: record.avatar as string | undefined, 1573 - likeCount: 0, 1574 - indexedAt: new Date(row.indexedat).toISOString(), 1575 - }); 1576 - } catch {} 1577 - } 1578 - } 1579 - return generators; 1580 - } 1581 - 1582 - // user feeds 1583 - 1584 - function queryAuthorFeed( 1585 - did: string, 1586 - cursor?: string 1587 - ): 1588 - | { 1589 - items: ATPAPI.AppBskyFeedDefs.FeedViewPost[]; 1590 - cursor: string | undefined; 1591 - } 1592 - | undefined { 1593 - if (!isRegisteredIndexUser(did)) return; 1594 - const db = getDbForDid(did); 1595 - if (!db) return; 1596 - 1597 - // TODO: implement this for real 1598 - let query = ` 1599 - SELECT uri, indexedat, cid 1600 - FROM app_bsky_feed_post 1601 - WHERE did = ? 1602 - `; 1603 - const params: (string | number)[] = [did]; 1604 - 1605 - if (cursor) { 1606 - const [indexedat, cid] = cursor.split("::"); 1607 - query += ` AND (indexedat < ? OR (indexedat = ? AND cid < ?))`; 1608 - params.push(parseInt(indexedat, 10), parseInt(indexedat, 10), cid); 1609 - } 1610 - 1611 - query += ` ORDER BY indexedat DESC, cid DESC LIMIT ${FEED_LIMIT}`; 1612 - 1613 - const stmt = db.prepare(query); 1614 - const rows = stmt.all(...params) as { 1615 - uri: string; 1616 - indexedat: number; 1617 - cid: string; 1618 - }[]; 1619 - 1620 - const items = rows 1621 - .map((row) => queryFeedViewPost(row.uri)) // TODO: for replies and repost i should inject the reason here 1622 - .filter((p): p is ATPAPI.AppBskyFeedDefs.FeedViewPost => !!p); 1623 - 1624 - const lastItem = rows[rows.length - 1]; 1625 - const nextCursor = lastItem 1626 - ? `${lastItem.indexedat}::${lastItem.cid}` 1627 - : undefined; 1628 - 1629 - return { items, cursor: nextCursor }; 1630 - } 1631 - 1632 - function queryListFeed( 1633 - uri: string, 1634 - cursor?: string 1635 - ): 1636 - | { 1637 - items: ATPAPI.AppBskyFeedDefs.FeedViewPost[]; 1638 - cursor: string | undefined; 1639 - } 1640 - | undefined { 1641 - return { items: [], cursor: undefined }; 1642 - } 1643 - 1644 - function queryActorLikes( 1645 - did: string, 1646 - cursor?: string 1647 - ): 1648 - | { 1649 - items: ATPAPI.AppBskyFeedDefs.FeedViewPost[]; 1650 - cursor: string | undefined; 1651 - } 1652 - | undefined { 1653 - if (!isRegisteredIndexUser(did)) return; 1654 - const db = getDbForDid(did); 1655 - if (!db) return; 1656 - 1657 - let query = ` 1658 - SELECT subject, indexedat, cid 1659 - FROM app_bsky_feed_like 1660 - WHERE did = ? 1661 - `; 1662 - const params: (string | number)[] = [did]; 1663 - 1664 - if (cursor) { 1665 - const [indexedat, cid] = cursor.split("::"); 1666 - query += ` AND (indexedat < ? OR (indexedat = ? AND cid < ?))`; 1667 - params.push(parseInt(indexedat, 10), parseInt(indexedat, 10), cid); 1668 - } 1669 - 1670 - query += ` ORDER BY indexedat DESC, cid DESC LIMIT ${FEED_LIMIT}`; 1671 - 1672 - const stmt = db.prepare(query); 1673 - const rows = stmt.all(...params) as { 1674 - subject: string; 1675 - indexedat: number; 1676 - cid: string; 1677 - }[]; 1678 - 1679 - const items = rows 1680 - .map((row) => queryFeedViewPost(row.subject)) 1681 - .filter((p): p is ATPAPI.AppBskyFeedDefs.FeedViewPost => !!p); 1682 - 1683 - const lastItem = rows[rows.length - 1]; 1684 - const nextCursor = lastItem 1685 - ? `${lastItem.indexedat}::${lastItem.cid}` 1686 - : undefined; 1687 - 1688 - return { items, cursor: nextCursor }; 1689 - } 1690 - 1691 - // post metadata 1692 - 1693 - function queryLikes( 1694 - uri: string 1695 - ): ATPAPI.AppBskyFeedGetLikes.Like[] | undefined { 1696 - const postUri = new AtUri(uri); 1697 - const postAuthorDid = postUri.hostname; 1698 - if (!isRegisteredIndexUser(postAuthorDid)) return; 1699 - const db = getDbForDid(postAuthorDid); 1700 - if (!db) return; 1701 - 1702 - const stmt = db.prepare(` 1703 - SELECT b.srcdid, b.srcuri 1704 - FROM backlink_skeleton AS b 1705 - WHERE b.suburi = ? AND b.srccol = 'app_bsky_feed_like' 1706 - ORDER BY b.id DESC; 1707 - `); 1708 - 1709 - const rows = stmt.all(uri) as unknown as BacklinkRow[]; 1710 - 1711 - return rows 1712 - .map((row) => { 1713 - const actor = queryProfileView(row.srcdid, ""); 1714 - if (!actor) return; 1715 - 1716 - return { 1717 - // TODO write indexedAt for spacedust indexes 1718 - createdAt: new Date(Date.now()).toISOString(), 1719 - indexedAt: new Date(Date.now()).toISOString(), 1720 - actor: actor, 1721 - }; 1722 - }) 1723 - .filter((like): like is ATPAPI.AppBskyFeedGetLikes.Like => !!like); 1724 - } 1725 - 1726 - function queryReposts(uri: string): ATPAPI.AppBskyActorDefs.ProfileView[] { 1727 - const postUri = new AtUri(uri); 1728 - const postAuthorDid = postUri.hostname; 1729 - if (!isRegisteredIndexUser(postAuthorDid)) return []; 1730 - const db = getDbForDid(postAuthorDid); 1731 - if (!db) return []; 1732 - 1733 - const stmt = db.prepare(` 1734 - SELECT srcdid 1735 - FROM backlink_skeleton 1736 - WHERE suburi = ? AND srccol = 'app_bsky_feed_repost' 1737 - ORDER BY id DESC; 1738 - `); 1739 - 1740 - const rows = stmt.all(uri) as { srcdid: string }[]; 1741 - 1742 - return rows 1743 - .map((row) => queryProfileView(row.srcdid, "")) 1744 - .filter((p): p is ATPAPI.AppBskyActorDefs.ProfileView => !!p); 1745 - } 1746 - 1747 - function queryQuotes(uri: string): ATPAPI.AppBskyFeedDefs.FeedViewPost[] { 1748 - const postUri = new AtUri(uri); 1749 - const postAuthorDid = postUri.hostname; 1750 - if (!isRegisteredIndexUser(postAuthorDid)) return []; 1751 - const db = getDbForDid(postAuthorDid); 1752 - if (!db) return []; 1753 - 1754 - const stmt = db.prepare(` 1755 - SELECT srcuri 1756 - FROM backlink_skeleton 1757 - WHERE suburi = ? AND srccol = 'app_bsky_feed_post' AND srcfield = 'quote' 1758 - ORDER BY id DESC; 1759 - `); 1760 - 1761 - const rows = stmt.all(uri) as { srcuri: string }[]; 1762 - 1763 - return rows 1764 - .map((row) => queryFeedViewPost(row.srcuri)) 1765 - .filter((p): p is ATPAPI.AppBskyFeedDefs.FeedViewPost => !!p); 1766 - } 1767 - 1768 - function queryPostThread( 1769 - uri: string 1770 - ): ATPAPI.AppBskyFeedGetPostThread.OutputSchema | undefined { 1771 - const post = queryPostView(uri); 1772 - if (!post) { 1773 - return { 1774 - thread: { 1775 - $type: "app.bsky.feed.defs#notFoundPost", 1776 - uri: uri, 1777 - notFound: true, 1778 - } as ATPAPI.$Typed<ATPAPI.AppBskyFeedDefs.NotFoundPost> 1779 - } 1780 - } 1781 - 1782 - const thread: ATPAPI.AppBskyFeedDefs.ThreadViewPost = { 1783 - $type: "app.bsky.feed.defs#threadViewPost", 1784 - post: post, 1785 - replies: [], 1786 - }; 1787 - 1788 - let current = thread; 1789 - while ((current.post.record.reply as any)?.parent?.uri) { 1790 - const parentUri = (current.post.record.reply as any)?.parent?.uri; 1791 - const parentPost = queryPostView(parentUri); 1792 - if (!parentPost) break; 1793 - 1794 - const parentThread: ATPAPI.AppBskyFeedDefs.ThreadViewPost = { 1795 - $type: "app.bsky.feed.defs#threadViewPost", 1796 - post: parentPost, 1797 - replies: [current as ATPAPI.$Typed<ATPAPI.AppBskyFeedDefs.ThreadViewPost>], 1798 - }; 1799 - current.parent = parentThread as ATPAPI.$Typed<ATPAPI.AppBskyFeedDefs.ThreadViewPost>; 1800 - current = parentThread; 1801 - } 1802 - 1803 - const seenUris = new Set<string>(); 1804 - const fetchReplies = ( 1805 - parentThread: ATPAPI.AppBskyFeedDefs.ThreadViewPost 1806 - ) => { 1807 - if (seenUris.has(parentThread.post.uri)) return; 1808 - seenUris.add(parentThread.post.uri); 1809 - 1810 - const parentUri = new AtUri(parentThread.post.uri); 1811 - const parentAuthorDid = parentUri.hostname; 1812 - const db = getDbForDid(parentAuthorDid); 1813 - if (!db) return; 1814 - 1815 - const stmt = db.prepare(` 1816 - SELECT srcuri 1817 - FROM backlink_skeleton 1818 - WHERE suburi = ? AND srccol = 'app_bsky_feed_post' AND srcfield = 'replyparent' 1819 - `); 1820 - const replyRows = stmt.all(parentThread.post.uri) as { srcuri: string }[]; 1821 - 1822 - const replies = replyRows 1823 - .map((row) => queryPostView(row.srcuri)) 1824 - .filter((p): p is ATPAPI.AppBskyFeedDefs.PostView => !!p); 1825 - 1826 - for (const replyPost of replies) { 1827 - const replyThread: ATPAPI.AppBskyFeedDefs.ThreadViewPost = { 1828 - $type: "app.bsky.feed.defs#threadViewPost", 1829 - post: replyPost, 1830 - parent: parentThread as ATPAPI.$Typed<ATPAPI.AppBskyFeedDefs.ThreadViewPost>, 1831 - replies: [], 1832 - }; 1833 - parentThread.replies?.push(replyThread as ATPAPI.$Typed<ATPAPI.AppBskyFeedDefs.ThreadViewPost>); 1834 - fetchReplies(replyThread); 1835 - } 1836 - }; 1837 - 1838 - fetchReplies(thread); 1839 - 1840 - const returned = thread as ATPAPI.$Typed<ATPAPI.AppBskyFeedDefs.ThreadViewPost> 1841 - 1842 - return { thread: returned }; 1843 - }
+12 -8
main.ts
··· 14 14 import * as ATPAPI from "npm:@atproto/api"; 15 15 import { didDocument } from "./utils/diddoc.ts"; 16 16 import { cachedFetch, searchParamsToJson, withCors } from "./utils/server.ts"; 17 - import { constellationAPIHandler, indexServerHandler, IndexServerUserManager } from "./indexserver.ts"; 17 + import { IndexServer, IndexServerConfig } from "./indexserver.ts" 18 18 import { viewServerHandler } from "./viewserver.ts"; 19 19 20 20 export const jetstreamurl = Deno.env.get("JETSTREAM_URL"); ··· 26 26 // AppView Setup 27 27 // ------------------------------------------ 28 28 29 - export const systemDB = new Database("system.db"); 30 - setupSystemDb(systemDB); 29 + const config: IndexServerConfig = { 30 + baseDbPath: './dbs', // The directory for user databases 31 + systemDbPath: './system.db', // The path for the main system database 32 + jetstreamUrl: jetstreamurl || "" 33 + }; 34 + const registeredUsersIndexServer = new IndexServer(config); 35 + setupSystemDb(registeredUsersIndexServer.systemDB); 31 36 32 37 // add me lol 33 - systemDB.exec(` 38 + registeredUsersIndexServer.systemDB.exec(` 34 39 INSERT OR IGNORE INTO users (did, role, registrationdate, onboardingstatus) 35 40 VALUES ( 36 41 'did:plc:mn45tewwnse5btfftvd3powc', ··· 48 53 ); 49 54 `) 50 55 51 - export const indexerUserManager = new IndexServerUserManager(); 52 - indexerUserManager.coldStart(systemDB) 56 + registeredUsersIndexServer.start(); 53 57 54 58 // should do both of these per user actually, since now each user has their own db 55 59 // also the set of records and backlinks to listen should be seperate between index and view servers ··· 152 156 // return await viewServerHandler(req) 153 157 154 158 if (constellation) { 155 - return await constellationAPIHandler(req); 159 + return registeredUsersIndexServer.constellationAPIHandler(req); 156 160 } 157 161 158 162 if (indexServerRoutes.has(pathname)) { 159 - return await indexServerHandler(req); 163 + return registeredUsersIndexServer.indexServerHandler(req); 160 164 } else { 161 165 return await viewServerHandler(req); 162 166 }
+2 -2
readme.md
··· 1 1 # skylite (pre alpha) 2 - an attempt to make a lightweight, easily self-hostable, scoped appview 2 + an attempt to make a lightweight, easily self-hostable, scoped Bluesky appview 3 3 4 4 this project uses: 5 5 - live sync systems: [jetstream](https://github.com/bluesky-social/jetstream) and [spacedust](https://spacedust.microcosm.blue/) ··· 22 22 - its a backlink index so i only needed one table, and so it is complete 23 23 - Server: 24 24 - Initial implementation is done 25 - - uses per-user instantiaion thing so it can add or remove users as needed 25 + - uses per-user instantiation thing so it can add or remove users as needed 26 26 - pagination is not a thing yet \:\( 27 27 - does not implement the Ref / Partial routes yet (currently strips undefineds) (fixing this soon) 28 28 - also implements the entirety of the Constellation API routes as a bonus (under `/links/`)
+2 -1
utils/identity.ts
··· 1 1 2 2 import { DidResolver, HandleResolver } from "npm:@atproto/identity"; 3 - import { systemDB } from "../main.ts"; 3 + import { Database } from "jsr:@db/sqlite@0.11"; 4 + const systemDB = new Database("./system.db") // TODO: temporary shim. should seperate this to its own central system db instead of the now instantiated system dbs 4 5 type DidMethod = "web" | "plc"; 5 6 type DidDoc = { 6 7 "@context"?: unknown;