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

Configure Feed

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

non-partial index server impl

rimar1337 d7f447ba a7628821

+727 -46
+1
deno.lock
··· 18 18 "npm:@atproto/lex-cli@*": "0.9.1", 19 19 "npm:@atproto/lexicon@*": "0.4.11", 20 20 "npm:@atproto/xrpc-server@*": "0.9.1", 21 + "npm:@atproto/xrpc@*": "0.7.1", 21 22 "npm:@ipld/car@*": "5.4.2", 22 23 "npm:@ipld/dag-cbor@*": "9.2.4", 23 24 "npm:@types/express@4.17.15": "4.17.15",
+722 -46
indexserver.ts
··· 11 11 import { handleJetstream } from "./index/jetstream.ts"; 12 12 import * as ATPAPI from "npm:@atproto/api"; 13 13 import { AtUri } from "npm:@atproto/api"; 14 + import * as IndexServerAPI from "./indexclient/index.ts"; 14 15 15 16 export class IndexServerUserManager { 16 17 private users = new Map<string, UserIndexServer>(); ··· 350 351 ? pathname.slice("/xrpc/".length) 351 352 : null; 352 353 const searchParams = searchParamsToJson(url.searchParams); 354 + console.log(JSON.stringify(searchParams, null, 2)); 353 355 const jsonUntyped = searchParams; 354 356 355 357 switch (xrpcMethod) { ··· 357 359 const jsonTyped = 358 360 jsonUntyped as IndexServerTypes.AppBskyActorGetProfile.QueryParams; 359 361 360 - const response: IndexServerTypes.AppBskyActorGetProfile.OutputSchema = {}; 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; 361 375 362 376 return new Response(JSON.stringify(response), { 363 377 headers: withCors({ "Content-Type": "application/json" }), ··· 366 380 case "app.bsky.actor.getProfiles": { 367 381 const jsonTyped = 368 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 + }; 369 399 370 - const response: IndexServerTypes.AppBskyActorGetProfiles.OutputSchema = 371 - {}; 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 + }; 372 429 373 430 return new Response(JSON.stringify(response), { 374 431 headers: withCors({ "Content-Type": "application/json" }), ··· 377 434 case "app.bsky.feed.getActorFeeds": { 378 435 const jsonTyped = 379 436 jsonUntyped as IndexServerTypes.AppBskyFeedGetActorFeeds.QueryParams; 437 + 438 + const qresult = queryActorFeeds(jsonTyped.actor) 380 439 381 440 const response: IndexServerTypes.AppBskyFeedGetActorFeeds.OutputSchema = 382 - {}; 441 + { 442 + feeds: qresult 443 + }; 383 444 384 445 return new Response(JSON.stringify(response), { 385 446 headers: withCors({ "Content-Type": "application/json" }), ··· 389 450 const jsonTyped = 390 451 jsonUntyped as IndexServerTypes.AppBskyFeedGetFeedGenerator.QueryParams; 391 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 + 392 466 const response: IndexServerTypes.AppBskyFeedGetFeedGenerator.OutputSchema = 393 - {}; 467 + { 468 + view: qresult, 469 + isOnline: true, // lmao 470 + isValid: true, // lmao 471 + }; 394 472 395 473 return new Response(JSON.stringify(response), { 396 474 headers: withCors({ "Content-Type": "application/json" }), ··· 400 478 const jsonTyped = 401 479 jsonUntyped as IndexServerTypes.AppBskyFeedGetFeedGenerators.QueryParams; 402 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 + 403 494 const response: IndexServerTypes.AppBskyFeedGetFeedGenerators.OutputSchema = 404 - {}; 495 + { 496 + feeds: qresult 497 + }; 405 498 406 499 return new Response(JSON.stringify(response), { 407 500 headers: withCors({ "Content-Type": "application/json" }), ··· 429 522 case "party.whey.app.bsky.feed.getActorLikesPartial": { 430 523 const jsonTyped = 431 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 + } 432 540 433 541 const response: IndexServerTypes.PartyWheyAppBskyFeedGetActorLikesPartial.OutputSchema = 434 - {}; 542 + { 543 + feed: qresult.items as ATPAPI.$Typed<ATPAPI.AppBskyFeedDefs.FeedViewPost>[], 544 + cursor: qresult.cursor 545 + }; 435 546 436 547 return new Response(JSON.stringify(response), { 437 548 headers: withCors({ "Content-Type": "application/json" }), ··· 441 552 const jsonTyped = 442 553 jsonUntyped as IndexServerTypes.PartyWheyAppBskyFeedGetAuthorFeedPartial.QueryParams; 443 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 + 444 570 const response: IndexServerTypes.PartyWheyAppBskyFeedGetAuthorFeedPartial.OutputSchema = 445 - {}; 571 + { 572 + feed: qresult.items as ATPAPI.$Typed<ATPAPI.AppBskyFeedDefs.FeedViewPost>[], 573 + cursor: qresult.cursor 574 + }; 446 575 447 576 return new Response(JSON.stringify(response), { 448 577 headers: withCors({ "Content-Type": "application/json" }), ··· 452 581 const jsonTyped = 453 582 jsonUntyped as IndexServerTypes.PartyWheyAppBskyFeedGetLikesPartial.QueryParams; 454 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 + } 455 598 const response: IndexServerTypes.PartyWheyAppBskyFeedGetLikesPartial.OutputSchema = 456 - {}; 599 + { 600 + // @ts-ignore whatever i dont care TODO: fix ts ignores 601 + likes: qresult 602 + }; 457 603 458 604 return new Response(JSON.stringify(response), { 459 605 headers: withCors({ "Content-Type": "application/json" }), ··· 463 609 const jsonTyped = 464 610 jsonUntyped as IndexServerTypes.PartyWheyAppBskyFeedGetPostThreadPartial.QueryParams; 465 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 + } 466 626 const response: IndexServerTypes.PartyWheyAppBskyFeedGetPostThreadPartial.OutputSchema = 467 - {}; 627 + qresult 468 628 469 629 return new Response(JSON.stringify(response), { 470 630 headers: withCors({ "Content-Type": "application/json" }), ··· 474 634 const jsonTyped = 475 635 jsonUntyped as IndexServerTypes.PartyWheyAppBskyFeedGetQuotesPartial.QueryParams; 476 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 + } 477 651 const response: IndexServerTypes.PartyWheyAppBskyFeedGetQuotesPartial.OutputSchema = 478 - {}; 652 + { 653 + uri: jsonTyped.uri, 654 + posts: qresult.map((feedviewpost)=>{return feedviewpost.post as ATPAPI.$Typed<ATPAPI.AppBskyFeedDefs.PostView>}) 655 + }; 479 656 480 657 return new Response(JSON.stringify(response), { 481 658 headers: withCors({ "Content-Type": "application/json" }), ··· 485 662 const jsonTyped = 486 663 jsonUntyped as IndexServerTypes.PartyWheyAppBskyFeedGetRepostedByPartial.QueryParams; 487 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 + } 488 679 const response: IndexServerTypes.PartyWheyAppBskyFeedGetRepostedByPartial.OutputSchema = 489 - {}; 680 + { 681 + uri: jsonTyped.uri, 682 + repostedBy: qresult as ATPAPI.$Typed<ATPAPI.AppBskyActorDefs.ProfileView>[] 683 + }; 490 684 491 685 return new Response(JSON.stringify(response), { 492 686 headers: withCors({ "Content-Type": "application/json" }), 493 687 }); 494 688 } 495 - case "party.whey.app.bsky.feed.getListFeedPartial": { 496 - const jsonTyped = 497 - jsonUntyped as IndexServerTypes.PartyWheyAppBskyFeedGetListFeedPartial.QueryParams; 689 + // TODO: too hard for now 690 + // case "party.whey.app.bsky.feed.getListFeedPartial": { 691 + // const jsonTyped = 692 + // jsonUntyped as IndexServerTypes.PartyWheyAppBskyFeedGetListFeedPartial.QueryParams; 498 693 499 - const response: IndexServerTypes.PartyWheyAppBskyFeedGetListFeedPartial.OutputSchema = 500 - {}; 694 + // const response: IndexServerTypes.PartyWheyAppBskyFeedGetListFeedPartial.OutputSchema = 695 + // {}; 501 696 502 - return new Response(JSON.stringify(response), { 503 - headers: withCors({ "Content-Type": "application/json" }), 504 - }); 505 - } 697 + // return new Response(JSON.stringify(response), { 698 + // headers: withCors({ "Content-Type": "application/json" }), 699 + // }); 700 + // } 506 701 /* three more coming soon 507 702 app.bsky.graph.getLists 508 703 app.bsky.graph.getList ··· 607 802 const jsonUntyped = searchParams; 608 803 609 804 if (!jsonUntyped.target) { 610 - return new Response(JSON.stringify({ error: "Missing required parameter: target" }), { 611 - status: 400, 612 - headers: withCors({ "Content-Type": "application/json" }), 613 - }); 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 + ); 614 812 } 615 813 616 814 const did = isDid(searchParams.target) ··· 629 827 ); 630 828 } 631 829 632 - const limit = 16 //Math.min(parseInt(searchParams.limit || "50", 10), 100); 830 + const limit = 16; //Math.min(parseInt(searchParams.limit || "50", 10), 100); 633 831 const offset = parseInt(searchParams.cursor || "0", 10); 634 832 635 833 switch (pathname) { 636 834 case "/links": { 637 835 const jsonTyped = jsonUntyped as linksQuery; 638 836 if (!jsonTyped.collection || !jsonTyped.path) { 639 - return new Response(JSON.stringify({ error: "Missing required parameters: collection, path" }), { 640 - status: 400, 641 - headers: withCors({ "Content-Type": "application/json" }), 642 - }); 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 + ); 643 846 } 644 847 645 848 const field = `${jsonTyped.collection}:${jsonTyped.path.replace( ··· 749 952 .get(jsonTyped.target, jsonTyped.collection, field); 750 953 751 954 const response: linksCountResponse = { 752 - total: (result && result.total) ? result.total.toString() : "0", 955 + total: result && result.total ? result.total.toString() : "0", 753 956 }; 754 957 755 958 return new Response(JSON.stringify(response), { ··· 780 983 .get(jsonTyped.target, jsonTyped.collection, field); 781 984 782 985 const response: linksCountResponse = { 783 - total: (result && result.total) ? result.total.toString() : "0", 986 + total: result && result.total ? result.total.toString() : "0", 784 987 }; 785 988 786 989 return new Response(JSON.stringify(response), { ··· 904 1107 case "app.bsky.feed.like": { 905 1108 return; 906 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 + } 907 1165 case "app.bsky.feed.post": { 908 1166 console.log("bsky post"); 909 1167 const stmt = db.prepare(` ··· 997 1255 } 998 1256 } 999 1257 1258 + // user data 1000 1259 function queryProfileView( 1001 1260 did: string, 1002 1261 type: "" ··· 1029 1288 `); 1030 1289 1031 1290 const row = stmt.get(did) as ProfileRow; 1032 - const profileView = queryProfileView(did, "Basic"); 1033 - if (!row || !row.cid || !profileView || !row.json) return; 1034 - const value = JSON.parse(row.json) as ATPAPI.AppBskyActorProfile.Record; 1035 1291 1036 1292 // simulate different types returned 1037 1293 switch (type) { ··· 1039 1295 const result: ATPAPI.AppBskyActorDefs.ProfileView = { 1040 1296 $type: "app.bsky.actor.defs#profileView", 1041 1297 did: did, 1042 - handle: "@idiot.fuck", // TODO: Resolve user identity here for the handle 1298 + handle: "idiot.fuck.shit.example.com", // TODO: Resolve user identity here for the handle 1043 1299 displayName: row.displayname ?? undefined, 1044 1300 description: row.description ?? undefined, 1045 1301 avatar: "https://google.com/", // create profile URL from resolved identity 1046 1302 //associated?: ProfileAssociated, 1047 - indexedAt: new Date(row.indexedat).toString() ?? undefined, 1303 + indexedAt: row.createdat 1304 + ? new Date(row.createdat).toISOString() 1305 + : undefined, 1048 1306 createdAt: row.createdat 1049 - ? new Date(row.createdat).toString() 1307 + ? new Date(row.createdat).toISOString() 1050 1308 : undefined, 1051 1309 //viewer?: ViewerState, 1052 1310 //labels?: ComAtprotoLabelDefs.Label[], ··· 1059 1317 const result: ATPAPI.AppBskyActorDefs.ProfileViewBasic = { 1060 1318 $type: "app.bsky.actor.defs#profileViewBasic", 1061 1319 did: did, 1062 - handle: "@idiot.fuck", // TODO: Resolve user identity here for the handle 1320 + handle: "idiot.fuck.shit.example.com", // TODO: Resolve user identity here for the handle 1063 1321 displayName: row.displayname ?? undefined, 1064 1322 avatar: "https://google.com/", // create profile URL from resolved identity 1065 1323 //associated?: ProfileAssociated, 1066 1324 createdAt: row.createdat 1067 - ? new Date(row.createdat).toString() 1325 + ? new Date(row.createdat).toISOString() 1068 1326 : undefined, 1069 1327 //viewer?: ViewerState, 1070 1328 //labels?: ComAtprotoLabelDefs.Label[], ··· 1101 1359 const postsResult = postsStmt.get(did) as { count: number }; 1102 1360 const postsCount = postsResult?.count ?? 0; 1103 1361 1104 - // -- end of changes -- 1105 1362 const result: ATPAPI.AppBskyActorDefs.ProfileViewDetailed = { 1106 1363 $type: "app.bsky.actor.defs#profileViewDetailed", 1107 1364 did: did, 1108 - handle: "@idiot.fuck", // TODO: Resolve user identity here for the handle 1365 + handle: "idiot.fuck.shit.example.com", // TODO: Resolve user identity here for the handle 1109 1366 displayName: row.displayname ?? undefined, 1110 1367 description: row.description ?? undefined, 1111 - avatar: "https://google.com/", // create profile URL from resolved identity 1368 + avatar: "https://google.com/", // TODO: create profile URL from resolved identity 1112 1369 banner: "https://youtube.com/", // same here 1113 1370 followersCount: followersCount, 1114 1371 followsCount: followsCount, 1115 1372 postsCount: postsCount, 1116 1373 //associated?: ProfileAssociated, 1117 1374 //joinedViaStarterPack?: // AppBskyGraphDefs.StarterPackViewBasic; 1118 - indexedAt: new Date(row.indexedat).toString() ?? undefined, 1375 + indexedAt: row.createdat 1376 + ? new Date(row.createdat).toISOString() 1377 + : undefined, 1119 1378 createdAt: row.createdat 1120 - ? new Date(row.createdat).toString() 1379 + ? new Date(row.createdat).toISOString() 1121 1380 : undefined, 1122 1381 //viewer?: ViewerState, 1123 1382 //labels?: ComAtprotoLabelDefs.Label[], ··· 1132 1391 } 1133 1392 } 1134 1393 1394 + // post hydration 1135 1395 function queryPostView( 1136 1396 uri: string 1137 1397 ): ATPAPI.AppBskyFeedDefs.PostView | undefined { ··· 1159 1419 author: profileView, 1160 1420 record: value, 1161 1421 indexedAt: new Date(row.indexedat).toISOString(), 1162 - // These can be filled in later if you support them 1163 1422 embed: value.embed, 1164 1423 }; 1165 1424 1166 1425 return post; 1167 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 + }
+2
main.ts
··· 149 149 console.log(`request for "${pathname}"`) 150 150 const constellation = pathname.startsWith("/links") 151 151 152 + // return await viewServerHandler(req) 153 + 152 154 if (constellation) { 153 155 return await constellationAPIHandler(req); 154 156 }
+2
utils/dbuser.ts
··· 33 33 avatarmime TEXT, 34 34 bannercid TEXT, 35 35 bannermime TEXT 36 + -- TODO please add pinned posts 36 37 ); 37 38 ${createIndexINE} idx_actor_profile_did ON app_bsky_actor_profile(did); 38 39 ··· 142 143 suburi TEXT, 143 144 subdid TEXT, 144 145 subcol TEXT 146 + -- TODO please add indexedAt 145 147 ); 146 148 ${createIndexINE} idx_backlink_subdid_mod ON backlink_skeleton(subdid, srcdid); 147 149 ${createIndexINE} idx_backlink_suburi_mod ON backlink_skeleton(suburi, srcdid);