[READ ONLY MIRROR] Spark Social AppView Server
github.com/sprksocial/server
atproto
deno
hono
lexicon
1import { Document, 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 extends Document {
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}
47export interface ImageMedia extends MediaRef {
48 alt: string;
49 aspectRatio: {
50 width: number;
51 height: number;
52 };
53}
54export interface VideoMedia extends MediaRef {
55 alt: string;
56 aspectRatio: {
57 width: number;
58 height: number;
59 };
60}
61interface Label {
62 src: string;
63 uri: string;
64 cid: string;
65 val: string;
66 neg: boolean;
67}
68interface Facet {
69 index: {
70 byteStart: number;
71 byteEnd: number;
72 };
73 features: Array<{
74 $type: string;
75 uri?: string;
76 did?: string;
77 tag?: string;
78 }>;
79}
80export interface PostMedia {
81 $type: string;
82 video?: VideoMedia;
83 images?: ImageMedia[];
84}
85export interface StoryMedia {
86 $type: string;
87 video?: VideoMedia;
88 image?: ImageMedia;
89}
90export interface Caption {
91 text: string;
92 facets?: Facet[];
93}
94
95// records
96
97export interface RecordDocument extends Document {
98 uri: string;
99 cid: string;
100 did: string;
101 collectionName: string;
102 rkey: string;
103 createdAt: string;
104 indexedAt: string;
105 json: string;
106 takenDown: boolean;
107 takedownRef: string;
108 invalidReplyRoot?: boolean;
109}
110export const recordSchema = new Schema<RecordDocument>({
111 uri: { type: String, required: true, unique: true, index: true },
112 cid: { type: String, required: true },
113 did: { type: String, required: true, index: true },
114 collectionName: { type: String, required: true, index: true },
115 rkey: { type: String, required: true },
116 createdAt: { type: String, required: true },
117 indexedAt: { type: String, required: true },
118 json: { type: String, required: true },
119 takenDown: { type: Boolean, required: false },
120 takedownRef: { type: String, required: false },
121 invalidReplyRoot: { type: Boolean, required: false },
122});
123
124// duplicate records
125
126export interface DuplicateRecordDocument extends Document {
127 uri: string;
128 cid: string;
129 duplicateOf: string;
130 indexedAt: string;
131}
132export const duplicateRecordSchema = new Schema<DuplicateRecordDocument>({
133 uri: { type: String, required: true, unique: true, index: true },
134 cid: { type: String, required: true },
135 duplicateOf: { type: String, required: true, index: true },
136 indexedAt: { type: String, required: true },
137});
138
139// actor sync
140
141export interface ActorSyncDocument extends Document {
142 did: string;
143 commitCid: string;
144 repoRev: string | null;
145}
146export const actorSyncSchema = new Schema<ActorSyncDocument>({
147 did: { type: String, required: true, unique: true, index: true },
148 commitCid: { type: String, required: true },
149 repoRev: { type: String, required: false, default: null },
150});
151
152// likes
153
154export interface LikeDocument extends AuthoredDocument {
155 subject: string;
156 subjectCid: string;
157 via?: string | null;
158 viaCid?: string | null;
159}
160export const likeSchema = new Schema<LikeDocument>({
161 ...authoredSchema,
162 subject: { type: String, required: true, index: true },
163 subjectCid: { type: String, required: true },
164 via: { type: String, required: false },
165 viaCid: { type: String, required: false },
166})
167 .index({ authorDid: 1, subject: 1 }, { unique: true })
168 .index({ subject: 1, createdAt: -1 });
169
170// follows
171
172export interface FollowDocument extends AuthoredDocument {
173 subject: string;
174}
175export const followSchema = new Schema<FollowDocument>({
176 ...authoredSchema,
177 subject: { type: String, required: true, index: true },
178})
179 .index({ authorDid: 1, subject: 1 }, { unique: true })
180 .index({ subject: 1, createdAt: -1 });
181
182// blocks
183
184export interface BlockDocument extends AuthoredDocument {
185 subject: string;
186}
187
188export const blockSchema = new Schema<BlockDocument>({
189 ...authoredSchema,
190 subject: { type: String, required: true, index: true },
191})
192 .index({ authorDid: 1, subject: 1 }, { unique: true })
193 .index({ subject: 1, createdAt: -1 });
194
195// profiles
196
197export interface ProfileDocument extends AuthoredDocument {
198 displayName?: string;
199 description?: string;
200 avatar?: MediaRef;
201 banner?: MediaRef;
202 labels?: Label[];
203 pinnedPost?: RecordRef;
204 postsCount: number;
205 followersCount: number;
206 followsCount: number;
207}
208export const profileSchema = new Schema<ProfileDocument>({
209 ...authoredSchema,
210 displayName: { type: String, required: false },
211 description: { type: String, required: false },
212 avatar: { type: Object, required: false },
213 banner: { type: Object, required: false },
214 labels: { type: [Object], required: false },
215 pinnedPost: { type: Object, required: false },
216 postsCount: { type: Number, required: true, default: 0 },
217 followersCount: { type: Number, required: true, default: 0 },
218 followsCount: { type: Number, required: true, default: 0 },
219})
220 .index({ displayName: "text", description: "text" });
221
222// audio
223
224export interface AudioDocument extends AuthoredDocument {
225 sound: MediaRef;
226 origin?: RecordRef;
227 title: string;
228 details?: {
229 artist?: string;
230 title?: string;
231 };
232 labels?: Label[];
233 useCount: number;
234}
235export const audioSchema = new Schema<AudioDocument>({
236 ...authoredSchema,
237 sound: { type: Object, required: true },
238 origin: { type: Object, required: false },
239 title: { type: String, required: true },
240 details: { type: Object, required: false },
241 labels: { type: [Object], required: false },
242 useCount: { type: Number, required: true, default: 0 },
243})
244 .index({ authorDid: 1, createdAt: -1 })
245 .index({ useCount: -1, createdAt: -1 });
246
247// reposts
248
249export interface RepostDocument extends AuthoredDocument {
250 subject: string;
251 subjectCid: string;
252 via?: string | null;
253 viaCid?: string | null;
254}
255export const repostSchema = new Schema<RepostDocument>({
256 ...authoredSchema,
257 subject: { type: String, required: true },
258 subjectCid: { type: String, required: true },
259 via: { type: String, required: false },
260 viaCid: { type: String, required: false },
261})
262 .index({ subject: 1, createdAt: -1 })
263 .index({ authorDid: 1, createdAt: -1 });
264
265// posts
266
267export interface PostDocument extends AuthoredDocument {
268 caption?: Caption;
269 media?: PostMedia;
270 sound?: RecordRef;
271 langs?: string[];
272 labels?: Label[];
273 tags?: string[];
274 likeCount: number;
275 replyCount: number;
276 repostCount: number;
277}
278export const postSchema = new Schema<PostDocument>({
279 ...authoredSchema,
280 caption: {
281 type: {
282 text: { type: String, required: true },
283 facets: { type: [Object], required: false, default: [] },
284 },
285 required: false,
286 },
287 media: { type: Object, required: false },
288 sound: {
289 type: {
290 uri: { type: String, required: true },
291 cid: { type: String, required: true },
292 },
293 required: false,
294 },
295 langs: { type: [String], required: false, default: [] },
296 labels: { type: [Object], required: false, default: [] },
297 tags: { type: [String], required: false, default: [] },
298 likeCount: { type: Number, required: true, default: 0 },
299 replyCount: { type: Number, required: true, default: 0 },
300 repostCount: { type: Number, required: true, default: 0 },
301})
302 .index({ authorDid: 1, createdAt: -1 })
303 .index({ tags: 1, createdAt: -1 });
304
305// replies
306
307export interface ReplyDocument extends AuthoredDocument {
308 text?: string;
309 facets?: Facet[];
310 reply?: {
311 root: RecordRef;
312 parent: RecordRef;
313 };
314 media?: ImageMedia;
315 langs?: string[];
316 labels?: Label[];
317 likeCount: number;
318 replyCount: number;
319}
320export const replySchema = new Schema<ReplyDocument>({
321 ...authoredSchema,
322 text: { type: String, required: false },
323 facets: { type: [Object], required: false, default: [] },
324 reply: {
325 type: {
326 root: {
327 uri: { type: String, required: true },
328 cid: { type: String, required: true },
329 },
330 parent: {
331 uri: { type: String, required: true },
332 cid: { type: String, required: true },
333 },
334 },
335 required: false,
336 },
337 media: { type: Object, required: false },
338 langs: { type: [String], required: false, default: [] },
339 labels: { type: [Object], required: false, default: [] },
340 likeCount: { type: Number, required: true, default: 0 },
341 replyCount: { type: Number, required: true, default: 0 },
342})
343 .index({ reply: 1, createdAt: -1 });
344
345// stories
346
347export interface StoryDocument extends AuthoredDocument {
348 media: StoryMedia;
349 sound?: RecordRef;
350 labels?: Label[];
351}
352export const storySchema = new Schema<StoryDocument>({
353 ...authoredSchema,
354 media: { type: Object, required: true },
355 sound: {
356 type: {
357 uri: { type: String, required: true },
358 cid: { type: String, required: true },
359 },
360 required: false,
361 },
362 labels: { type: [Object], required: false, default: [] },
363})
364 .index({ authorDid: 1, createdAt: -1 });
365
366// generators
367
368export interface GeneratorDocument extends AuthoredDocument {
369 displayName: string;
370 description?: string;
371 descriptionFacets?: Facet[];
372 avatar?: MediaRef;
373 acceptsInteractions?: boolean;
374 labels?: Label[];
375 likeCount: number;
376}
377export const generatorSchema = new Schema<GeneratorDocument>({
378 ...authoredSchema,
379 displayName: { type: String, required: true },
380 description: { type: String, required: false },
381 descriptionFacets: { type: [Object], required: false },
382 avatar: { type: Object, required: false },
383 acceptsInteractions: { type: Boolean, required: false },
384 labels: { type: [Object], required: false },
385 likeCount: { type: Number, required: false, default: 0 },
386})
387 .index({ authorDid: 1, createdAt: -1 });
388
389// takedowns
390
391export interface TakedownDocument extends Document {
392 targetUri: string;
393 targetCid: string;
394 reason: string;
395 takenDownBy: string;
396 takenDownAt: string;
397 ref: string | null;
398 applied: boolean;
399}
400export const takedownSchema = new Schema<TakedownDocument>({
401 targetUri: { type: String, required: true, unique: true, index: true },
402 targetCid: { type: String, required: true },
403 reason: { type: String, required: true },
404 takenDownBy: { type: String, required: true },
405 takenDownAt: { type: String, required: true },
406 ref: { type: String, required: false },
407 applied: { type: Boolean, required: true, default: false },
408});
409
410// repo takedowns
411
412export interface RepoTakedownDocument extends Document {
413 did: string;
414 reason: string;
415 takenDownBy: string;
416 takenDownAt: string;
417 ref: string | null;
418 applied: boolean;
419}
420export const repoTakedownSchema = new Schema<RepoTakedownDocument>({
421 did: { type: String, required: true, unique: true, index: true },
422 reason: { type: String, required: true },
423 takenDownBy: { type: String, required: true },
424 takenDownAt: { type: String, required: true },
425 ref: { type: String, required: false, default: null },
426 applied: { type: Boolean, required: true, default: false },
427});
428
429// blobs takedowns
430
431export interface BlobTakedownDocument extends Document {
432 did: string;
433 cid: string;
434 reason: string;
435 takenDownBy: string;
436 takenDownAt: string;
437 ref: string | null;
438 applied: boolean;
439}
440export const blobTakedownSchema = new Schema<BlobTakedownDocument>({
441 did: { type: String, required: true, index: true },
442 cid: { type: String, required: true, index: true },
443 reason: { type: String, required: true },
444 takenDownBy: { type: String, required: true },
445 takenDownAt: { type: String, required: true },
446 ref: { type: String, required: false, default: null },
447 applied: { type: Boolean, required: true, default: false },
448})
449 .index({ did: 1, cid: 1 }, { unique: true });
450
451// actors
452
453export interface ActorDocument extends Document {
454 did: string;
455 handle: string | null;
456 indexedAt: string;
457 takedownRef: string | null;
458 upstreamStatus: string | null;
459 keys: string[];
460 services: string;
461}
462export const actorSchema = new Schema<ActorDocument>({
463 did: { type: String, required: true, unique: true, index: true },
464 handle: { type: String, required: false, index: true },
465 indexedAt: { type: String, required: true },
466 takedownRef: { type: String, required: false },
467 upstreamStatus: { type: String, required: false },
468 keys: { type: [String], required: true },
469 services: { type: String, required: true },
470});
471
472// preferences
473
474export interface PreferenceDocument extends Document {
475 userDid: string;
476 contentLabelPrefs?: Array<{
477 labelerDid?: string;
478 label: string;
479 visibility: string;
480 }>;
481 savedFeeds?: Array<{
482 id: string;
483 type: string;
484 value: string;
485 pinned: boolean;
486 }>;
487 personalDetailsPref?: {
488 birthDate?: string;
489 };
490 feedViewPrefs?: Array<{
491 feed: string;
492 hideReplies?: boolean;
493 hideRepliesByUnfollowed: boolean;
494 hideRepliesByLikeCount?: number;
495 hideRepliesByLookCount?: number;
496 hideReposts?: boolean;
497 hideQuotePosts?: boolean;
498 }>;
499 threadViewPref?: {
500 sort?: string;
501 };
502 interestsPref?: {
503 tags: string[];
504 };
505 mutedWordsPref?: {
506 items: Array<{
507 id?: string;
508 value: string;
509 targets: string[];
510 actorTarget: string;
511 expiresAt?: string;
512 }>;
513 };
514 hiddenPostsPref?: {
515 items: string[];
516 };
517 labelersPref?: {
518 labelers: Array<{
519 did: string;
520 }>;
521 };
522 postInteractionSettingsPref?: {
523 threadgateAllowRules?: Array<{
524 $type: string;
525 [key: string]: unknown;
526 }>;
527 };
528 createdAt: string;
529 updatedAt: string;
530}
531export const preferenceSchema = new Schema<PreferenceDocument>({
532 userDid: { type: String, required: true, unique: true, index: true },
533 contentLabelPrefs: { type: [Object], required: false },
534 savedFeeds: { type: [Object], required: false },
535 personalDetailsPref: { type: Object, required: false },
536 feedViewPrefs: { type: [Object], required: false },
537 threadViewPref: { type: Object, required: false },
538 interestsPref: { type: Object, required: false },
539 mutedWordsPref: { type: Object, required: false },
540 hiddenPostsPref: { type: Object, required: false },
541 labelersPref: { type: Object, required: false },
542 postInteractionSettingsPref: { type: Object, required: false },
543 createdAt: { type: String, required: true },
544 updatedAt: { type: String, required: true },
545});
546
547// cursor state
548
549export interface CursorStateDocument extends Document {
550 identifier: string; // To ensure a single document, e.g., 'last_processed_cursor'
551 cursorValue: number;
552 updatedAt: Date;
553}
554export const cursorStateSchema = new Schema<CursorStateDocument>({
555 identifier: { type: String, required: true, unique: true, index: true },
556 cursorValue: { type: Number, required: true },
557 updatedAt: { type: Date, default: Date.now },
558});
559
560// Apply plugin to schemas that extend AuthoredDocument
561([
562 profileSchema,
563 likeSchema,
564 postSchema,
565 replySchema,
566 repostSchema,
567 followSchema,
568 blockSchema,
569 generatorSchema,
570 audioSchema,
571 storySchema,
572] as Schema[]).forEach((s) => s.plugin(addAuthor));
573
574export interface DatabaseModels {
575 Record: Model<RecordDocument>;
576 DuplicateRecord: Model<DuplicateRecordDocument>;
577 Like: Model<LikeDocument>;
578 Post: Model<PostDocument>;
579 Reply: Model<ReplyDocument>;
580 Story: Model<StoryDocument>;
581 Follow: Model<FollowDocument>;
582 Block: Model<BlockDocument>;
583 Profile: Model<ProfileDocument>;
584 Audio: Model<AudioDocument>;
585 Repost: Model<RepostDocument>;
586 Generator: Model<GeneratorDocument>;
587 Takedown: Model<TakedownDocument>;
588 RepoTakedown: Model<RepoTakedownDocument>;
589 BlobTakedown: Model<BlobTakedownDocument>;
590 Actor: Model<ActorDocument>;
591 ActorSync: Model<ActorSyncDocument>;
592 Preference: Model<PreferenceDocument>;
593 CursorState: Model<CursorStateDocument>;
594}