[READ ONLY MIRROR] Spark Social AppView Server
github.com/sprksocial/server
atproto
deno
hono
lexicon
1import { Model, Schema } from "mongoose";
2
3interface RecordRef {
4 uri: string;
5 cid: string;
6}
7
8// Plugin for adding author DID population to schemas
9function addAuthor(schema: Schema) {
10 // Only add if schema has authorDid field
11 if (schema.paths.authorDid) {
12 schema.virtual("actor", {
13 ref: "Actor",
14 localField: "authorDid",
15 foreignField: "did",
16 justOne: true,
17 });
18
19 // Ensure virtual fields are serialized
20 schema.set("toJSON", { virtuals: true });
21 schema.set("toObject", { virtuals: true });
22 }
23}
24
25// Base interface for documents with authorDid
26interface AuthoredDocument {
27 uri: string;
28 cid: string;
29 createdAt: string;
30 indexedAt: string;
31 authorDid: string;
32 actor?: ActorDocument; // Virtual field for populated actor data
33}
34
35export const authoredSchema = {
36 uri: { type: String, required: true, unique: true, index: true },
37 authorDid: { type: String, required: true, index: true },
38 cid: { type: String, required: true },
39 createdAt: { type: String, required: true },
40 indexedAt: { type: String, required: true },
41};
42
43export interface MediaRef {
44 $type: string;
45 ref: { $link: string };
46 mimeType?: string;
47 size?: number;
48}
49export interface ImageMedia extends MediaRef {
50 alt: string;
51 aspectRatio: {
52 width: number;
53 height: number;
54 };
55}
56export interface VideoMedia extends MediaRef {
57 alt: string;
58 aspectRatio: {
59 width: number;
60 height: number;
61 };
62}
63interface Label {
64 src: string;
65 uri: string;
66 cid: string;
67 val: string;
68 neg: boolean;
69}
70interface Facet {
71 index: {
72 byteStart: number;
73 byteEnd: number;
74 };
75 features: Array<{
76 $type: string;
77 uri?: string;
78 did?: string;
79 tag?: string;
80 }>;
81}
82export interface PostMedia {
83 $type: string;
84 video?: VideoMedia;
85 images?: ImageMedia[];
86}
87export interface StoryMedia {
88 $type: string;
89 video?: VideoMedia;
90 image?: ImageMedia;
91 alt?: string;
92 aspectRatio?: {
93 width: number;
94 height: number;
95 };
96}
97export interface Caption {
98 text: string;
99 facets?: Facet[];
100}
101
102// records
103
104export interface RecordDocument {
105 uri: string;
106 cid: string;
107 did: string;
108 collectionName: string;
109 rkey: string;
110 createdAt: string;
111 indexedAt: string;
112 json: string;
113 takenDown: boolean;
114 takedownRef: string;
115 invalidReplyRoot?: boolean;
116}
117export const recordSchema = new Schema<RecordDocument>({
118 uri: { type: String, required: true, unique: true, index: true },
119 cid: { type: String, required: true },
120 did: { type: String, required: true, index: true },
121 collectionName: { type: String, required: true, index: true },
122 rkey: { type: String, required: true },
123 createdAt: { type: String, required: true },
124 indexedAt: { type: String, required: true },
125 json: { type: String, required: true },
126 takenDown: { type: Boolean, required: false },
127 takedownRef: { type: String, required: false },
128 invalidReplyRoot: { type: Boolean, required: false },
129});
130
131// duplicate records
132
133export interface DuplicateRecordDocument {
134 uri: string;
135 cid: string;
136 duplicateOf: string;
137 indexedAt: string;
138}
139export const duplicateRecordSchema = new Schema<DuplicateRecordDocument>({
140 uri: { type: String, required: true, unique: true, index: true },
141 cid: { type: String, required: true },
142 duplicateOf: { type: String, required: true, index: true },
143 indexedAt: { type: String, required: true },
144});
145
146// actor sync
147
148export interface ActorSyncDocument {
149 did: string;
150 commitCid: string;
151 repoRev: string | null;
152}
153export const actorSyncSchema = new Schema<ActorSyncDocument>({
154 did: { type: String, required: true, unique: true, index: true },
155 commitCid: { type: String, required: true },
156 repoRev: { type: String, required: false, default: null },
157});
158
159// likes
160
161export interface LikeDocument extends AuthoredDocument {
162 subject: string;
163 subjectCid: string;
164 via?: string | null;
165 viaCid?: string | null;
166}
167export const likeSchema = new Schema<LikeDocument>({
168 ...authoredSchema,
169 subject: { type: String, required: true, index: true },
170 subjectCid: { type: String, required: true },
171 via: { type: String, required: false },
172 viaCid: { type: String, required: false },
173})
174 .index({ authorDid: 1, subject: 1 }, { unique: true })
175 .index({ subject: 1, createdAt: -1 })
176 .index({ subject: 1, authorDid: 1 });
177
178// follows
179
180export interface FollowDocument extends AuthoredDocument {
181 subject: string;
182}
183export const followSchema = new Schema<FollowDocument>({
184 ...authoredSchema,
185 subject: { type: String, required: true, index: true },
186})
187 .index({ authorDid: 1, subject: 1 }, { unique: true })
188 .index({ subject: 1, createdAt: -1 })
189 .index({ subject: 1, authorDid: 1 });
190
191// blocks
192
193export interface BlockDocument extends AuthoredDocument {
194 subject: string;
195}
196
197export const blockSchema = new Schema<BlockDocument>({
198 ...authoredSchema,
199 subject: { type: String, required: true, index: true },
200})
201 .index({ authorDid: 1, subject: 1 }, { unique: true })
202 .index({ subject: 1, createdAt: -1 })
203 .index({ subject: 1, authorDid: 1 });
204
205// profiles
206
207export interface ProfileDocument extends AuthoredDocument {
208 displayName?: string;
209 description?: string;
210 avatar?: MediaRef;
211 banner?: MediaRef;
212 labels?: Label[];
213 pinnedPost?: RecordRef;
214 postsCount: number;
215 followersCount: number;
216 followsCount: number;
217}
218export const profileSchema = new Schema<ProfileDocument>({
219 ...authoredSchema,
220 displayName: { type: String, required: false },
221 description: { type: String, required: false },
222 avatar: { type: Object, required: false },
223 banner: { type: Object, required: false },
224 labels: { type: [Object], required: false },
225 pinnedPost: { type: Object, required: false },
226 postsCount: { type: Number, required: true, default: 0 },
227 followersCount: { type: Number, required: true, default: 0 },
228 followsCount: { type: Number, required: true, default: 0 },
229})
230 .index({ displayName: "text", description: "text" });
231
232// audio
233
234export interface AudioDocument extends AuthoredDocument {
235 sound: MediaRef;
236 origin?: RecordRef;
237 title: string;
238 details?: {
239 artist?: string;
240 title?: string;
241 };
242 labels?: Label[];
243 useCount: number;
244}
245export const audioSchema = new Schema<AudioDocument>({
246 ...authoredSchema,
247 sound: { type: Object, required: true },
248 origin: { type: Object, required: false },
249 title: { type: String, required: true },
250 details: { type: Object, required: false },
251 labels: { type: [Object], required: false },
252 useCount: { type: Number, required: true, default: 0 },
253})
254 .index({ authorDid: 1, createdAt: -1 })
255 .index({ useCount: -1, createdAt: -1 });
256
257// reposts
258
259export interface RepostDocument extends AuthoredDocument {
260 subject: string;
261 subjectCid: string;
262 via?: string | null;
263 viaCid?: string | null;
264}
265export const repostSchema = new Schema<RepostDocument>({
266 ...authoredSchema,
267 subject: { type: String, required: true },
268 subjectCid: { type: String, required: true },
269 via: { type: String, required: false },
270 viaCid: { type: String, required: false },
271})
272 .index({ subject: 1, createdAt: -1 })
273 .index({ authorDid: 1, createdAt: -1 })
274 .index({ subject: 1, authorDid: 1 });
275
276// posts
277
278export interface PostDocument extends AuthoredDocument {
279 caption?: Caption;
280 media?: PostMedia;
281 sound?: RecordRef;
282 langs?: string[];
283 labels?: Label[];
284 tags?: string[];
285 crossposts?: RecordRef[];
286 likeCount: number;
287 replyCount: number;
288 repostCount: number;
289}
290export const postSchema = new Schema<PostDocument>({
291 ...authoredSchema,
292 caption: {
293 type: {
294 text: { type: String, required: true },
295 facets: { type: [Object], required: false, default: [] },
296 },
297 required: false,
298 },
299 media: { type: Object, required: false },
300 sound: {
301 type: {
302 uri: { type: String, required: true },
303 cid: { type: String, required: true },
304 },
305 required: false,
306 },
307 langs: { type: [String], required: false, default: [] },
308 labels: { type: [Object], required: false, default: [] },
309 tags: { type: [String], required: false, default: [] },
310 crossposts: { type: [Object], required: false, default: [] },
311 likeCount: { type: Number, required: true, default: 0 },
312 replyCount: { type: Number, required: true, default: 0 },
313 repostCount: { type: Number, required: true, default: 0 },
314})
315 .index({ authorDid: 1, createdAt: -1 })
316 .index({ tags: 1, createdAt: -1 });
317
318// replies
319
320export interface ReplyDocument extends AuthoredDocument {
321 text?: string;
322 facets?: Facet[];
323 reply?: {
324 root: RecordRef;
325 parent: RecordRef;
326 };
327 media?: ImageMedia | { images?: ImageMedia[]; [key: string]: unknown };
328 langs?: string[];
329 labels?: Label[];
330 invalidReplyRoot?: boolean;
331 likeCount: number;
332 replyCount: number;
333}
334export const replySchema = new Schema<ReplyDocument>({
335 ...authoredSchema,
336 text: { type: String, required: false },
337 facets: { type: [Object], required: false, default: [] },
338 reply: {
339 type: {
340 root: {
341 uri: { type: String, required: true },
342 cid: { type: String, required: true },
343 },
344 parent: {
345 uri: { type: String, required: true },
346 cid: { type: String, required: true },
347 },
348 },
349 required: false,
350 },
351 media: { type: Object, required: false },
352 langs: { type: [String], required: false, default: [] },
353 labels: { type: [Object], required: false, default: [] },
354 invalidReplyRoot: { type: Boolean, required: false },
355 likeCount: { type: Number, required: true, default: 0 },
356 replyCount: { type: Number, required: true, default: 0 },
357})
358 .index({ reply: 1, createdAt: -1 })
359 .index({ "reply.parent.uri": 1, authorDid: 1 })
360 .index({ "reply.root.uri": 1, createdAt: -1 });
361
362// crosspost replies
363
364export interface CrosspostReplyDocument extends AuthoredDocument {
365 text?: string;
366 facets?: Facet[];
367 reply?: {
368 root: RecordRef;
369 parent: RecordRef;
370 };
371 langs?: string[];
372 labels?: Label[];
373 tags?: string[];
374 invalidReplyRoot?: boolean;
375 likeCount: number;
376 replyCount: number;
377}
378export const crosspostReplySchema = new Schema<CrosspostReplyDocument>({
379 ...authoredSchema,
380 text: { type: String, required: false },
381 facets: { type: [Object], required: false, default: [] },
382 reply: {
383 type: {
384 root: {
385 uri: { type: String, required: true },
386 cid: { type: String, required: true },
387 },
388 parent: {
389 uri: { type: String, required: true },
390 cid: { type: String, required: true },
391 },
392 },
393 required: false,
394 },
395 langs: { type: [String], required: false, default: [] },
396 labels: { type: [Object], required: false, default: [] },
397 tags: { type: [String], required: false, default: [] },
398 invalidReplyRoot: { type: Boolean, required: false },
399 likeCount: { type: Number, required: true, default: 0 },
400 replyCount: { type: Number, required: true, default: 0 },
401})
402 .index({ reply: 1, createdAt: -1 })
403 .index({ "reply.parent.uri": 1, authorDid: 1 })
404 .index({ "reply.root.uri": 1, createdAt: -1 });
405
406// stories
407
408export interface StoryDocument extends AuthoredDocument {
409 media: StoryMedia;
410 sound?: RecordRef;
411 labels?: Label[];
412}
413export const storySchema = new Schema<StoryDocument>({
414 ...authoredSchema,
415 media: { type: Object, required: true },
416 sound: {
417 type: {
418 uri: { type: String, required: true },
419 cid: { type: String, required: true },
420 },
421 required: false,
422 },
423 labels: { type: [Object], required: false, default: [] },
424})
425 .index({ authorDid: 1, createdAt: -1 });
426
427// generators
428
429export interface GeneratorDocument extends AuthoredDocument {
430 displayName: string;
431 description?: string;
432 descriptionFacets?: Facet[];
433 avatar?: MediaRef;
434 acceptsInteractions?: boolean;
435 labels?: Label[];
436 likeCount: number;
437}
438export const generatorSchema = new Schema<GeneratorDocument>({
439 ...authoredSchema,
440 displayName: { type: String, required: true },
441 description: { type: String, required: false },
442 descriptionFacets: { type: [Object], required: false },
443 avatar: { type: Object, required: false },
444 acceptsInteractions: { type: Boolean, required: false },
445 labels: { type: [Object], required: false },
446 likeCount: { type: Number, required: false, default: 0 },
447})
448 .index({ authorDid: 1, createdAt: -1 });
449
450// labelers
451
452export interface LabelerDocument extends AuthoredDocument {}
453
454export const labelerSchema = new Schema<LabelerDocument>({
455 ...authoredSchema,
456})
457 .index({ authorDid: 1, createdAt: -1 });
458
459// labels
460
461export interface LabelDocument {
462 src: string;
463 uri: string;
464 cid: string;
465 val: string;
466 neg: boolean;
467 cts: string;
468 exp: string | null;
469}
470
471export const labelSchema = new Schema<LabelDocument>({
472 src: { type: String, required: true, index: true },
473 uri: { type: String, required: true, index: true },
474 cid: { type: String, required: true },
475 val: { type: String, required: true, index: true },
476 neg: { type: Boolean, required: true },
477 cts: { type: String, required: true },
478 exp: { type: String, required: false, default: null },
479})
480 .index({ uri: 1, src: 1, val: 1 }, { unique: true })
481 .index({ src: 1, cts: -1 });
482
483// takedowns
484
485export interface TakedownDocument {
486 targetUri: string;
487 targetCid: string;
488 reason: string;
489 takenDownBy: string;
490 takenDownAt: string;
491 ref: string | null;
492 applied: boolean;
493}
494export const takedownSchema = new Schema<TakedownDocument>({
495 targetUri: { type: String, required: true, unique: true, index: true },
496 targetCid: { type: String, required: true },
497 reason: { type: String, required: true },
498 takenDownBy: { type: String, required: true },
499 takenDownAt: { type: String, required: true },
500 ref: { type: String, required: false },
501 applied: { type: Boolean, required: true, default: false },
502});
503
504// repo takedowns
505
506export interface RepoTakedownDocument {
507 did: string;
508 reason: string;
509 takenDownBy: string;
510 takenDownAt: string;
511 ref: string | null;
512 applied: boolean;
513}
514export const repoTakedownSchema = new Schema<RepoTakedownDocument>({
515 did: { type: String, required: true, unique: true, index: true },
516 reason: { type: String, required: true },
517 takenDownBy: { type: String, required: true },
518 takenDownAt: { type: String, required: true },
519 ref: { type: String, required: false, default: null },
520 applied: { type: Boolean, required: true, default: false },
521});
522
523// blobs takedowns
524
525export interface BlobTakedownDocument {
526 did: string;
527 cid: string;
528 reason: string;
529 takenDownBy: string;
530 takenDownAt: string;
531 ref: string | null;
532 applied: boolean;
533}
534export const blobTakedownSchema = new Schema<BlobTakedownDocument>({
535 did: { type: String, required: true, index: true },
536 cid: { type: String, required: true, index: true },
537 reason: { type: String, required: true },
538 takenDownBy: { type: String, required: true },
539 takenDownAt: { type: String, required: true },
540 ref: { type: String, required: false, default: null },
541 applied: { type: Boolean, required: true, default: false },
542})
543 .index({ did: 1, cid: 1 }, { unique: true });
544
545// actors
546
547export interface ActorDocument {
548 did: string;
549 handle: string | null;
550 indexedAt: string;
551 takedownRef: string | null;
552 upstreamStatus: string | null;
553 keys: string[];
554 services: string;
555 lastSeenNotifs: string | null;
556}
557export const actorSchema = new Schema<ActorDocument>({
558 did: { type: String, required: true, unique: true, index: true },
559 handle: { type: String, required: false, index: true },
560 indexedAt: { type: String, required: true },
561 takedownRef: { type: String, required: false },
562 upstreamStatus: { type: String, required: false },
563 keys: { type: [String], required: true },
564 services: { type: String, required: true },
565 lastSeenNotifs: { type: String, required: false, default: null },
566});
567
568// preferences
569
570export interface PreferenceDocument {
571 userDid: string;
572 contentLabelPrefs?: Array<{
573 labelerDid?: string;
574 label: string;
575 visibility: string;
576 }>;
577 savedFeeds?: Array<{
578 id: string;
579 type: string;
580 value: string;
581 pinned: boolean;
582 }>;
583 personalDetailsPref?: {
584 birthDate?: string;
585 };
586 feedViewPrefs?: Array<{
587 feed: string;
588 hideReplies?: boolean;
589 hideRepliesByUnfollowed: boolean;
590 hideRepliesByLikeCount?: number;
591 hideReposts?: boolean;
592 hideQuotePosts?: boolean;
593 }>;
594 threadViewPref?: {
595 sort?: string;
596 };
597 interestsPref?: {
598 tags: string[];
599 };
600 mutedWordsPref?: {
601 items: Array<{
602 id?: string;
603 value: string;
604 targets: string[];
605 actorTarget: string;
606 expiresAt?: string;
607 }>;
608 };
609 hiddenPostsPref?: {
610 items: string[];
611 };
612 labelersPref?: {
613 labelers: Array<{
614 did: string;
615 }>;
616 };
617 postInteractionSettingsPref?: {
618 threadgateAllowRules?: Array<{
619 $type: string;
620 [key: string]: unknown;
621 }>;
622 };
623 createdAt: string;
624 updatedAt: string;
625}
626export const preferenceSchema = new Schema<PreferenceDocument>({
627 userDid: { type: String, required: true, unique: true, index: true },
628 contentLabelPrefs: { type: [Object], required: false },
629 savedFeeds: { type: [Object], required: false },
630 personalDetailsPref: { type: Object, required: false },
631 feedViewPrefs: { type: [Object], required: false },
632 threadViewPref: { type: Object, required: false },
633 interestsPref: { type: Object, required: false },
634 mutedWordsPref: { type: Object, required: false },
635 hiddenPostsPref: { type: Object, required: false },
636 labelersPref: { type: Object, required: false },
637 postInteractionSettingsPref: { type: Object, required: false },
638 createdAt: { type: String, required: true },
639 updatedAt: { type: String, required: true },
640});
641
642// cursor state
643
644export interface CursorStateDocument {
645 identifier: string; // To ensure a single document, e.g., 'last_processed_cursor'
646 cursorValue: number;
647 updatedAt: Date;
648}
649export const cursorStateSchema = new Schema<CursorStateDocument>({
650 identifier: { type: String, required: true, unique: true, index: true },
651 cursorValue: { type: Number, required: true },
652 updatedAt: { type: Date, default: Date.now },
653});
654
655// notifications
656
657export interface NotificationDocument {
658 did: string;
659 recordUri: string;
660 recordCid: string;
661 author: string;
662 reason: string;
663 reasonSubject: string | null;
664 sortAt: string;
665}
666export const notificationSchema = new Schema<NotificationDocument>({
667 did: { type: String, required: true, index: true },
668 recordUri: { type: String, required: true, index: true },
669 recordCid: { type: String, required: true },
670 author: { type: String, required: true, index: true },
671 reason: { type: String, required: true },
672 reasonSubject: { type: String, required: false, default: null },
673 sortAt: { type: String, required: true, index: true },
674})
675 .index({ did: 1, sortAt: -1 })
676 .index({ did: 1, reason: 1, sortAt: -1 });
677
678// push tokens
679
680export interface PushTokenDocument {
681 did: string;
682 token: string;
683 platform: "ios" | "android" | "web";
684 appId: string;
685 serviceDid: string;
686 createdAt: string;
687 updatedAt: string;
688}
689export const pushTokenSchema = new Schema<PushTokenDocument>({
690 did: { type: String, required: true, index: true },
691 token: { type: String, required: true },
692 platform: { type: String, required: true, enum: ["ios", "android", "web"] },
693 appId: { type: String, required: true },
694 serviceDid: { type: String, required: true },
695 createdAt: { type: String, required: true },
696 updatedAt: { type: String, required: true },
697})
698 .index({ did: 1, token: 1, platform: 1, appId: 1 }, { unique: true });
699
700// Apply plugin to schemas that extend AuthoredDocument
701([
702 profileSchema,
703 likeSchema,
704 postSchema,
705 replySchema,
706 crosspostReplySchema,
707 repostSchema,
708 followSchema,
709 blockSchema,
710 generatorSchema,
711 audioSchema,
712 storySchema,
713 labelerSchema,
714] as Schema[]).forEach((s) => s.plugin(addAuthor));
715
716export interface DatabaseModels {
717 Record: Model<RecordDocument>;
718 DuplicateRecord: Model<DuplicateRecordDocument>;
719 Like: Model<LikeDocument>;
720 Post: Model<PostDocument>;
721 Reply: Model<ReplyDocument>;
722 CrosspostReply: Model<CrosspostReplyDocument>;
723 Story: Model<StoryDocument>;
724 Follow: Model<FollowDocument>;
725 Block: Model<BlockDocument>;
726 Profile: Model<ProfileDocument>;
727 Audio: Model<AudioDocument>;
728 Repost: Model<RepostDocument>;
729 Generator: Model<GeneratorDocument>;
730 Labeler: Model<LabelerDocument>;
731 Label: Model<LabelDocument>;
732 Takedown: Model<TakedownDocument>;
733 RepoTakedown: Model<RepoTakedownDocument>;
734 BlobTakedown: Model<BlobTakedownDocument>;
735 Actor: Model<ActorDocument>;
736 ActorSync: Model<ActorSyncDocument>;
737 Preference: Model<PreferenceDocument>;
738 CursorState: Model<CursorStateDocument>;
739 Notification: Model<NotificationDocument>;
740 PushToken: Model<PushTokenDocument>;
741}