···5import * as IndexServerTypes from "./utils/indexservertypes.ts";
6import { Database } from "jsr:@db/sqlite@0.11";
7import { setupUserDb } from "./utils/dbuser.ts";
8-import { indexerUserManager, jetstreamurl, systemDB } from "./main.ts";
9import { JetstreamManager, SpacedustManager } from "./utils/sharders.ts";
10import { handleSpacedust, SpacedustLinkMessage } from "./index/spacedust.ts";
11import { handleJetstream } from "./index/jetstream.ts";
···13import { AtUri } from "npm:@atproto/api";
14import * as IndexServerAPI from "./indexclient/index.ts";
150000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000016export class IndexServerUserManager {
00000017 private users = new Map<string, UserIndexServer>();
1819 /*async*/ addUser(did: string) {
20 if (this.users.has(did)) return;
21- const instance = new UserIndexServer(did);
22 //await instance.initialize();
23 this.users.set(did, instance);
24 }
···60}
6162class UserIndexServer {
063 did: string;
64 db: Database; // | undefined;
65 jetstream: JetstreamManager; // | undefined;
66 spacedust: SpacedustManager; // | undefined;
6768- constructor(did: string) {
69 this.did = did;
70- this.db = internalCreateDbForDid(this.did);
071 // should probably put the params of exactly what were listening to here
72 this.jetstream = new JetstreamManager((msg) => {
73 console.log("Received Jetstream message: ", msg);
···79 const value = msg.commit.record;
8081 if (!doer || !value) return;
82- indexServerIndexer({
83 op,
84 doer,
85 cid: msg.commit.cid,
···227 }
228}
229230-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-}
255256-function getDbForDid(did: string): Database | undefined {
257- const db = indexerUserManager.getDbForDid(did);
258- if (!db) return;
259- return db;
260-}
261262// async function connectToJetstream(did: string, db: Database): Promise<WebSocket> {
263// const url = `${jetstreamurl}/xrpc/com.atproto.sync.subscribeRepos?did=${did}`;
···342 bannermime: string | null;
343};
344345-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-724type linksQuery = {
725 target: string;
726 collection: string;
···795 return typeof str === "string" && str.startsWith("did:");
796}
797798-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-1034function isImageEmbed(embed: unknown): embed is ATPAPI.AppBskyEmbedImages.Main {
1035 return (
1036 typeof embed === "object" &&
···1096 if (isRecordWithMediaEmbed(embed)) return embed.record.record.uri;
1097 return null;
1098}
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-}
···5import * as IndexServerTypes from "./utils/indexservertypes.ts";
6import { Database } from "jsr:@db/sqlite@0.11";
7import { setupUserDb } from "./utils/dbuser.ts";
8+// import { systemDB } from "./main.ts";
9import { JetstreamManager, SpacedustManager } from "./utils/sharders.ts";
10import { handleSpacedust, SpacedustLinkMessage } from "./index/spacedust.ts";
11import { handleJetstream } from "./index/jetstream.ts";
···13import { AtUri } from "npm:@atproto/api";
14import * as IndexServerAPI from "./indexclient/index.ts";
1516+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+1449export class IndexServerUserManager {
1450+ public indexServer: IndexServer;
1451+1452+ constructor(indexServer: IndexServer) {
1453+ this.indexServer = indexServer;
1454+ }
1455+1456 private users = new Map<string, UserIndexServer>();
14571458 /*async*/ addUser(did: string) {
1459 if (this.users.has(did)) return;
1460+ const instance = new UserIndexServer(this, did);
1461 //await instance.initialize();
1462 this.users.set(did, instance);
1463 }
···1499}
15001501class UserIndexServer {
1502+ public indexServerUserManager: IndexServerUserManager;
1503 did: string;
1504 db: Database; // | undefined;
1505 jetstream: JetstreamManager; // | undefined;
1506 spacedust: SpacedustManager; // | undefined;
15071508+ constructor(indexServerUserManager: IndexServerUserManager, did: string) {
1509 this.did = did;
1510+ this.indexServerUserManager = indexServerUserManager;
1511+ this.db = this.indexServerUserManager.indexServer.internalCreateDbForDid(this.did);
1512 // should probably put the params of exactly what were listening to here
1513 this.jetstream = new JetstreamManager((msg) => {
1514 console.log("Received Jetstream message: ", msg);
···1520 const value = msg.commit.record;
15211522 if (!doer || !value) return;
1523+ this.indexServerUserManager.indexServer.indexServerIndexer({
1524 op,
1525 doer,
1526 cid: msg.commit.cid,
···1668 }
1669}
16701671+// /**
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+// }
000000000000016831684+// function getDbForDid(did: string): Database | undefined {
1685+// const db = indexerUserManager.getDbForDid(did);
1686+// if (!db) return;
1687+// return db;
1688+// }
16891690// async function connectToJetstream(did: string, db: Database): Promise<WebSocket> {
1691// const url = `${jetstreamurl}/xrpc/com.atproto.sync.subscribeRepos?did=${did}`;
···1770 bannermime: string | null;
1771};
177200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001773type linksQuery = {
1774 target: string;
1775 collection: string;
···1844 return typeof str === "string" && str.startsWith("did:");
1845}
1846000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001847function isImageEmbed(embed: unknown): embed is ATPAPI.AppBskyEmbedImages.Main {
1848 return (
1849 typeof embed === "object" &&
···1909 if (isRecordWithMediaEmbed(embed)) return embed.record.record.uri;
1910 return null;
1911}
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
+12-8
main.ts
···14import * as ATPAPI from "npm:@atproto/api";
15import { didDocument } from "./utils/diddoc.ts";
16import { cachedFetch, searchParamsToJson, withCors } from "./utils/server.ts";
17-import { constellationAPIHandler, indexServerHandler, IndexServerUserManager } from "./indexserver.ts";
18import { viewServerHandler } from "./viewserver.ts";
1920export const jetstreamurl = Deno.env.get("JETSTREAM_URL");
···26// AppView Setup
27// ------------------------------------------
2829-export const systemDB = new Database("system.db");
30-setupSystemDb(systemDB);
000003132// add me lol
33-systemDB.exec(`
34 INSERT OR IGNORE INTO users (did, role, registrationdate, onboardingstatus)
35 VALUES (
36 'did:plc:mn45tewwnse5btfftvd3powc',
···48 );
49`)
5051-export const indexerUserManager = new IndexServerUserManager();
52-indexerUserManager.coldStart(systemDB)
5354// should do both of these per user actually, since now each user has their own db
55// also the set of records and backlinks to listen should be seperate between index and view servers
···152 // return await viewServerHandler(req)
153154 if (constellation) {
155- return await constellationAPIHandler(req);
156 }
157158 if (indexServerRoutes.has(pathname)) {
159- return await indexServerHandler(req);
160 } else {
161 return await viewServerHandler(req);
162 }
···14import * as ATPAPI from "npm:@atproto/api";
15import { didDocument } from "./utils/diddoc.ts";
16import { cachedFetch, searchParamsToJson, withCors } from "./utils/server.ts";
17+import { IndexServer, IndexServerConfig } from "./indexserver.ts"
18import { viewServerHandler } from "./viewserver.ts";
1920export const jetstreamurl = Deno.env.get("JETSTREAM_URL");
···26// AppView Setup
27// ------------------------------------------
2829+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);
3637// add me lol
38+registeredUsersIndexServer.systemDB.exec(`
39 INSERT OR IGNORE INTO users (did, role, registrationdate, onboardingstatus)
40 VALUES (
41 'did:plc:mn45tewwnse5btfftvd3powc',
···53 );
54`)
5556+registeredUsersIndexServer.start();
05758// should do both of these per user actually, since now each user has their own db
59// also the set of records and backlinks to listen should be seperate between index and view servers
···156 // return await viewServerHandler(req)
157158 if (constellation) {
159+ return registeredUsersIndexServer.constellationAPIHandler(req);
160 }
161162 if (indexServerRoutes.has(pathname)) {
163+ return registeredUsersIndexServer.indexServerHandler(req);
164 } else {
165 return await viewServerHandler(req);
166 }
+2-2
readme.md
···1# skylite (pre alpha)
2-an attempt to make a lightweight, easily self-hostable, scoped appview
34this project uses:
5- live sync systems: [jetstream](https://github.com/bluesky-social/jetstream) and [spacedust](https://spacedust.microcosm.blue/)
···22 - its a backlink index so i only needed one table, and so it is complete
23- Server:
24 - Initial implementation is done
25- - uses per-user instantiaion thing so it can add or remove users as needed
26 - pagination is not a thing yet \:\(
27 - does not implement the Ref / Partial routes yet (currently strips undefineds) (fixing this soon)
28 - also implements the entirety of the Constellation API routes as a bonus (under `/links/`)
···1# skylite (pre alpha)
2+an attempt to make a lightweight, easily self-hostable, scoped Bluesky appview
34this project uses:
5- live sync systems: [jetstream](https://github.com/bluesky-social/jetstream) and [spacedust](https://spacedust.microcosm.blue/)
···22 - its a backlink index so i only needed one table, and so it is complete
23- Server:
24 - Initial implementation is done
25+ - uses per-user instantiation thing so it can add or remove users as needed
26 - pagination is not a thing yet \:\(
27 - does not implement the Ref / Partial routes yet (currently strips undefineds) (fixing this soon)
28 - also implements the entirety of the Constellation API routes as a bonus (under `/links/`)
···12import { DidResolver, HandleResolver } from "npm:@atproto/identity";
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
5type DidMethod = "web" | "plc";
6type DidDoc = {
7 "@context"?: unknown;