an attempt to make a lightweight, easily self-hostable, scoped bluesky appview
1import { indexHandlerContext } from "./index/types.ts";
2
3import { assertRecord, validateRecord } from "./utils/records.ts";
4import { searchParamsToJson, withCors } from "./utils/server.ts";
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";
12import * as ATPAPI from "npm:@atproto/api";
13import { AtUri } from "npm:@atproto/api";
14import * as IndexServerAPI from "./indexclient/index.ts";
15
16export interface IndexServerConfig {
17 baseDbPath: string;
18 systemDbPath: string;
19 jetstreamUrl: string;
20}
21
22interface 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}
31interface GeneratorRow extends BaseRow {
32 displayname: string | null;
33 description: string | null;
34 avatarcid: string | null;
35}
36interface LikeRow extends BaseRow {
37 subject: string;
38}
39interface RepostRow extends BaseRow {
40 subject: string;
41}
42interface BacklinkRow {
43 srcuri: string;
44 srcdid: string;
45}
46
47const FEED_LIMIT = 50;
48
49export 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>();
1457
1458 /*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 }
1464
1465 // async handleRequest({
1466 // did,
1467 // route,
1468 // req,
1469 // }: {
1470 // did: string;
1471 // route: string;
1472 // req: Request;
1473 // }) {
1474 // if (!this.users.has(did)) await this.addUser(did);
1475 // const user = this.users.get(did)!;
1476 // return await user.handleHttpRequest(route, req);
1477 // }
1478
1479 removeUser(did: string) {
1480 const instance = this.users.get(did);
1481 if (!instance) return;
1482 /*await*/ instance.shutdown();
1483 this.users.delete(did);
1484 }
1485
1486 getDbForDid(did: string): Database | null {
1487 if (!this.users.has(did)) {
1488 return null;
1489 }
1490 return this.users.get(did)?.db ?? null;
1491 }
1492
1493 coldStart(db: Database) {
1494 const rows = db.prepare("SELECT did FROM users").all();
1495 for (const row of rows) {
1496 this.addUser(row.did);
1497 }
1498 }
1499}
1500
1501class UserIndexServer {
1502 public indexServerUserManager: IndexServerUserManager;
1503 did: string;
1504 db: Database; // | undefined;
1505 jetstream: JetstreamManager; // | undefined;
1506 spacedust: SpacedustManager; // | undefined;
1507
1508 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);
1515
1516 const op = msg.commit.operation;
1517 const doer = msg.did;
1518 const rev = msg.commit.rev;
1519 const aturi = `${msg.did}/${msg.commit.collection}/${msg.commit.rkey}`;
1520 const value = msg.commit.record;
1521
1522 if (!doer || !value) return;
1523 this.indexServerUserManager.indexServer.indexServerIndexer({
1524 op,
1525 doer,
1526 cid: msg.commit.cid,
1527 rev,
1528 aturi,
1529 value,
1530 indexsrc: `jetstream-${op}`,
1531 db: this.db,
1532 });
1533 });
1534 this.jetstream.start({
1535 // for realsies pls get from db or something instead of this shit
1536 wantedDids: [
1537 this.did,
1538 // "did:plc:mn45tewwnse5btfftvd3powc",
1539 // "did:plc:yy6kbriyxtimkjqonqatv2rb",
1540 // "did:plc:zzhzjga3ab5fcs2vnsv2ist3",
1541 // "did:plc:jz4ibztn56hygfld6j6zjszg",
1542 ],
1543 wantedCollections: [
1544 "app.bsky.actor.profile",
1545 "app.bsky.feed.generator",
1546 "app.bsky.feed.like",
1547 "app.bsky.feed.post",
1548 "app.bsky.feed.repost",
1549 "app.bsky.feed.threadgate",
1550 "app.bsky.graph.block",
1551 "app.bsky.graph.follow",
1552 "app.bsky.graph.list",
1553 "app.bsky.graph.listblock",
1554 "app.bsky.graph.listitem",
1555 "app.bsky.notification.declaration",
1556 ],
1557 });
1558 //await connectToJetstream(this.did, this.db);
1559 this.spacedust = new SpacedustManager((msg: SpacedustLinkMessage) => {
1560 console.log("Received Spacedust message: ", msg);
1561 const operation = msg.link.operation;
1562
1563 const sourceURI = new AtUri(msg.link.source_record);
1564 const srcUri = msg.link.source_record;
1565 const srcDid = sourceURI.host;
1566 const srcField = msg.link.source;
1567 const srcCol = sourceURI.collection;
1568 const subjectURI = new AtUri(msg.link.subject);
1569 const subUri = msg.link.subject;
1570 const subDid = subjectURI.host;
1571 const subCol = subjectURI.collection;
1572
1573 if (operation === "delete") {
1574 this.db.run(
1575 `DELETE FROM backlink_skeleton
1576 WHERE srcuri = ? AND srcfield = ? AND suburi = ?`,
1577 [srcUri, srcField, subUri]
1578 );
1579 } else if (operation === "create") {
1580 this.db.run(
1581 `INSERT OR REPLACE INTO backlink_skeleton (
1582 srcuri,
1583 srcdid,
1584 srcfield,
1585 srccol,
1586 suburi,
1587 subdid,
1588 subcol
1589 ) VALUES (?, ?, ?, ?, ?, ?, ?)`,
1590 [
1591 srcUri, // full AT URI of the source record
1592 srcDid, // did: of the source
1593 srcField, // e.g., "reply.parent.uri" or "facets.features.did"
1594 srcCol, // e.g., "app.bsky.feed.post"
1595 subUri, // full AT URI of the subject (linked record)
1596 subDid, // did: of the subject
1597 subCol, // subject collection (can be inferred or passed)
1598 ]
1599 );
1600 }
1601 });
1602 this.spacedust.start({
1603 wantedSources: [
1604 "app.bsky.feed.like:subject.uri", // like
1605 "app.bsky.feed.like:via.uri", // liked repost
1606 "app.bsky.feed.repost:subject.uri", // repost
1607 "app.bsky.feed.repost:via.uri", // reposted repost
1608 "app.bsky.feed.post:reply.root.uri", // thread OP
1609 "app.bsky.feed.post:reply.parent.uri", // direct parent
1610 "app.bsky.feed.post:embed.media.record.record.uri", // quote with media
1611 "app.bsky.feed.post:embed.record.uri", // quote without media
1612 "app.bsky.feed.threadgate:post", // threadgate subject
1613 "app.bsky.feed.threadgate:hiddenReplies", // threadgate items (array)
1614 "app.bsky.feed.post:facets.features.did", // facet item (array): mention
1615 "app.bsky.graph.block:subject", // blocks
1616 "app.bsky.graph.follow:subject", // follow
1617 "app.bsky.graph.listblock:subject", // list item (blocks)
1618 "app.bsky.graph.listblock:list", // blocklist mention (might not exist)
1619 "app.bsky.graph.listitem:subject", // list item (blocks)
1620 "app.bsky.graph.listitem:list", // list mention
1621 ],
1622 // should be getting from DB but whatever right
1623 wantedSubjects: [
1624 // as noted i dont need to write down each post, just the user to listen to !
1625 // hell yeah
1626 // "at://did:plc:mn45tewwnse5btfftvd3powc/app.bsky.feed.post/3lvybv7b6ic2h",
1627 // "at://did:plc:mn45tewwnse5btfftvd3powc/app.bsky.feed.post/3lvybws4avc2h",
1628 // "at://did:plc:mn45tewwnse5btfftvd3powc/app.bsky.feed.post/3lvvkcxcscs2h",
1629 // "at://did:plc:yy6kbriyxtimkjqonqatv2rb/app.bsky.feed.post/3l63ogxocq42f",
1630 // "at://did:plc:yy6kbriyxtimkjqonqatv2rb/app.bsky.feed.post/3lw3wamvflu23",
1631 ],
1632 wantedSubjectDids: [
1633 this.did,
1634 //"did:plc:mn45tewwnse5btfftvd3powc",
1635 //"did:plc:yy6kbriyxtimkjqonqatv2rb",
1636 //"did:plc:zzhzjga3ab5fcs2vnsv2ist3",
1637 //"did:plc:jz4ibztn56hygfld6j6zjszg",
1638 ],
1639 });
1640 //await connectToConstellation(this.did, this.db);
1641 }
1642
1643 // initialize() {
1644
1645 // }
1646
1647 // async handleHttpRequest(route: string, req: Request): Promise<Response> {
1648 // if (route === "posts") {
1649 // const posts = await this.queryPosts();
1650 // return new Response(JSON.stringify(posts), {
1651 // headers: { "content-type": "application/json" },
1652 // });
1653 // }
1654
1655 // return new Response("Unknown route", { status: 404 });
1656 // }
1657
1658 // private async queryPosts() {
1659 // return this.db.run(
1660 // "SELECT * FROM posts ORDER BY created_at DESC LIMIT 100"
1661 // );
1662 // }
1663
1664 shutdown() {
1665 this.jetstream.stop();
1666 this.spacedust.stop();
1667 this.db.close?.();
1668 }
1669}
1670
1671// /**
1672// * please do not use this, use openDbForDid() instead
1673// * @param did
1674// * @returns
1675// */
1676// function internalCreateDbForDid(did: string): Database {
1677// const path = `./dbs/${did}.sqlite`;
1678// const db = new Database(path);
1679// setupUserDb(db);
1680// //await db.exec(/* CREATE IF NOT EXISTS statements */);
1681// return db;
1682// }
1683
1684// function getDbForDid(did: string): Database | undefined {
1685// const db = indexerUserManager.getDbForDid(did);
1686// if (!db) return;
1687// return db;
1688// }
1689
1690// async function connectToJetstream(did: string, db: Database): Promise<WebSocket> {
1691// const url = `${jetstreamurl}/xrpc/com.atproto.sync.subscribeRepos?did=${did}`;
1692// const ws = new WebSocket(url);
1693// ws.onmessage = (msg) => {
1694// //handleJetstreamMessage(evt.data, db)
1695
1696// const op = msg.commit.operation;
1697// const doer = msg.did;
1698// const rev = msg.commit.rev;
1699// const aturi = `${msg.did}/${msg.commit.collection}/${msg.commit.rkey}`;
1700// const value = msg.commit.record;
1701
1702// if (!doer || !value) return;
1703// indexServerIndexer({
1704// op,
1705// doer,
1706// rev,
1707// aturi,
1708// value,
1709// indexsrc: "onboarding_backfill",
1710// userdbname: did,
1711// })
1712// };
1713
1714// return ws;
1715// }
1716
1717// async function connectToConstellation(did: string, db: D1Database): Promise<WebSocket> {
1718// const url = `wss://bsky.social/xrpc/com.atproto.sync.subscribeLabels?did=${did}`;
1719// const ws = new WebSocket(url);
1720// ws.onmessage = (evt) => handleConstellationMessage(evt.data, db);
1721// return ws;
1722// }
1723
1724type PostRow = {
1725 uri: string;
1726 did: string;
1727 cid: string | null;
1728 rev: string | null;
1729 createdat: number | null;
1730 indexedat: number;
1731 json: string | null;
1732
1733 text: string | null;
1734 replyroot: string | null;
1735 replyparent: string | null;
1736 quote: string | null;
1737
1738 imagecount: number | null;
1739 image1cid: string | null;
1740 image1mime: string | null;
1741 image1aspect: string | null;
1742 image2cid: string | null;
1743 image2mime: string | null;
1744 image2aspect: string | null;
1745 image3cid: string | null;
1746 image3mime: string | null;
1747 image3aspect: string | null;
1748 image4cid: string | null;
1749 image4mime: string | null;
1750 image4aspect: string | null;
1751
1752 videocount: number | null;
1753 videocid: string | null;
1754 videomime: string | null;
1755 videoaspect: string | null;
1756};
1757
1758type ProfileRow = {
1759 uri: string;
1760 cid: string | null;
1761 rev: string | null;
1762 createdat: number | null;
1763 indexedat: number;
1764 json: string | null;
1765 displayname: string | null;
1766 description: string | null;
1767 avatarcid: string | null;
1768 avatarmime: string | null;
1769 bannercid: string | null;
1770 bannermime: string | null;
1771};
1772
1773type linksQuery = {
1774 target: string;
1775 collection: string;
1776 path: string;
1777 cursor?: string;
1778};
1779type linksRecord = {
1780 did: string;
1781 collection: string;
1782 rkey: string;
1783};
1784type linksRecordsResponse = {
1785 total: string;
1786 linking_records: linksRecord[];
1787 cursor?: string;
1788};
1789type linksDidsResponse = {
1790 total: string;
1791 linking_dids: string[];
1792 cursor?: string;
1793};
1794type linksCountResponse = {
1795 total: string;
1796};
1797type linksAllResponse = {
1798 links: Record<
1799 string,
1800 Record<
1801 string,
1802 {
1803 records: number;
1804 distinct_dids: number;
1805 }
1806 >
1807 >;
1808};
1809
1810type linksAllQuery = {
1811 target: string;
1812};
1813
1814const SQL = {
1815 links: `
1816 SELECT srcuri, srcdid, srccol
1817 FROM backlink_skeleton
1818 WHERE suburi = ? AND srccol = ? AND srcfield = ?
1819 `,
1820 distinctDids: `
1821 SELECT DISTINCT srcdid
1822 FROM backlink_skeleton
1823 WHERE suburi = ? AND srccol = ? AND srcfield = ?
1824 `,
1825 count: `
1826 SELECT COUNT(*) as total
1827 FROM backlink_skeleton
1828 WHERE suburi = ? AND srccol = ? AND srcfield = ?
1829 `,
1830 countDistinctDids: `
1831 SELECT COUNT(DISTINCT srcdid) as total
1832 FROM backlink_skeleton
1833 WHERE suburi = ? AND srccol = ? AND srcfield = ?
1834 `,
1835 all: `
1836 SELECT suburi, srccol, COUNT(*) as records, COUNT(DISTINCT srcdid) as distinct_dids
1837 FROM backlink_skeleton
1838 WHERE suburi = ?
1839 GROUP BY suburi, srccol
1840 `,
1841};
1842
1843export function isDid(str: string): boolean {
1844 return typeof str === "string" && str.startsWith("did:");
1845}
1846
1847function isImageEmbed(embed: unknown): embed is ATPAPI.AppBskyEmbedImages.Main {
1848 return (
1849 typeof embed === "object" &&
1850 embed !== null &&
1851 "$type" in embed &&
1852 (embed as any).$type === "app.bsky.embed.images"
1853 );
1854}
1855
1856function isVideoEmbed(embed: unknown): embed is ATPAPI.AppBskyEmbedVideo.Main {
1857 return (
1858 typeof embed === "object" &&
1859 embed !== null &&
1860 "$type" in embed &&
1861 (embed as any).$type === "app.bsky.embed.video"
1862 );
1863}
1864
1865function isRecordEmbed(
1866 embed: unknown
1867): embed is ATPAPI.AppBskyEmbedRecord.Main {
1868 return (
1869 typeof embed === "object" &&
1870 embed !== null &&
1871 "$type" in embed &&
1872 (embed as any).$type === "app.bsky.embed.record"
1873 );
1874}
1875
1876function isRecordWithMediaEmbed(
1877 embed: unknown
1878): embed is ATPAPI.AppBskyEmbedRecordWithMedia.Main {
1879 return (
1880 typeof embed === "object" &&
1881 embed !== null &&
1882 "$type" in embed &&
1883 (embed as any).$type === "app.bsky.embed.recordWithMedia"
1884 );
1885}
1886
1887function uncid(anything: any): string | null {
1888 return (
1889 ((anything as Record<string, unknown>)?.["$link"] as string | null) || null
1890 );
1891}
1892
1893function extractImages(embed: unknown) {
1894 if (isImageEmbed(embed)) return embed.images;
1895 if (isRecordWithMediaEmbed(embed) && isImageEmbed(embed.media))
1896 return embed.media.images;
1897 return [];
1898}
1899
1900function extractVideo(embed: unknown) {
1901 if (isVideoEmbed(embed)) return embed;
1902 if (isRecordWithMediaEmbed(embed) && isVideoEmbed(embed.media))
1903 return embed.media;
1904 return null;
1905}
1906
1907function extractQuoteUri(embed: unknown): string | null {
1908 if (isRecordEmbed(embed)) return embed.record.uri;
1909 if (isRecordWithMediaEmbed(embed)) return embed.record.record.uri;
1910 return null;
1911}