[READ ONLY MIRROR] Spark Social AppView Server github.com/sprksocial/server
atproto deno hono lexicon
5
fork

Configure Feed

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

feat: stories archive endpoint

+735 -40
+2
api/index.ts
··· 21 21 import resolveHandle from "./com/atproto/identity/resolveHandle.ts"; 22 22 import getStories from "./so/sprk/story/getStories.ts"; 23 23 import getStoriesTimeline from "./so/sprk/story/getTimeline.ts"; 24 + import getStoriesArchive from "./so/sprk/story/getArchive.ts"; 24 25 import getProfiles from "./so/sprk/actor/getProfiles.ts"; 25 26 import searchPosts from "./so/sprk/feed/searchPosts.ts"; 26 27 import getActorAudios from "./so/sprk/sound/getActorAudios.ts"; ··· 60 61 resolveHandle(server, ctx); 61 62 getStories(server, ctx); 62 63 getStoriesTimeline(server, ctx); 64 + getStoriesArchive(server, ctx); 63 65 searchPosts(server, ctx); 64 66 getActorAudios(server, ctx); 65 67 getTrendingAudios(server, ctx);
+143
api/so/sprk/story/getArchive.ts
··· 1 + import { InvalidRequestError } from "@atp/xrpc-server"; 2 + import { mapDefined } from "@atp/common"; 3 + import { AppContext } from "../../../../context.ts"; 4 + import { HydrateCtx, HydrationState } from "../../../../hydration/index.ts"; 5 + import { parseString } from "../../../../hydration/util.ts"; 6 + import { Server } from "../../../../lex/index.ts"; 7 + import { 8 + OutputSchema, 9 + QueryParams, 10 + } from "../../../../lex/types/so/sprk/story/getArchive.ts"; 11 + import { 12 + createPipeline, 13 + HydrationFnInput, 14 + PresentationFnInput, 15 + RulesFnInput, 16 + SkeletonFnInput, 17 + } from "../../../../pipeline.ts"; 18 + import { uriToDid } from "../../../../utils/uris.ts"; 19 + import { resHeaders } from "../../../util.ts"; 20 + 21 + const MAX_LIMIT = 100; 22 + const DEFAULT_LIMIT = 50; 23 + 24 + export default function (server: Server, ctx: AppContext) { 25 + const getArchive = createPipeline(skeleton, hydration, rules, presentation); 26 + server.so.sprk.story.getArchive({ 27 + auth: ctx.authVerifier.standard, 28 + handler: async ({ params, auth, req }) => { 29 + const { includeTakedowns } = ctx.authVerifier.parseCreds(auth); 30 + const viewer = auth.credentials.iss; 31 + const labelers = ctx.reqLabelers(req); 32 + const hydrateCtx = await ctx.hydrator.createContext({ 33 + viewer, 34 + labelers, 35 + includeTakedowns, 36 + }); 37 + 38 + const { limit: limitParam = DEFAULT_LIMIT, cursor } = params; 39 + const limit = typeof limitParam === "string" 40 + ? parseInt(limitParam, 10) 41 + : limitParam; 42 + 43 + if (isNaN(limit) || limit < 1 || limit > MAX_LIMIT) { 44 + throw new InvalidRequestError( 45 + `Invalid limit: must be between 1 and ${MAX_LIMIT}`, 46 + ); 47 + } 48 + 49 + const [result, repoRev] = await Promise.all([ 50 + getArchive( 51 + { 52 + ...params, 53 + limit, 54 + cursor, 55 + hydrateCtx: hydrateCtx.copy({ viewer, includeTakedowns }), 56 + }, 57 + ctx, 58 + ), 59 + ctx.hydrator.actor.getRepoRevSafe(viewer), 60 + ]); 61 + 62 + return { 63 + encoding: "application/json", 64 + body: result, 65 + headers: resHeaders({ 66 + repoRev, 67 + labelers: hydrateCtx.labelers, 68 + }), 69 + }; 70 + }, 71 + }); 72 + } 73 + 74 + const skeleton = async ( 75 + inputs: SkeletonFnInput<Context, Params>, 76 + ): Promise<Skeleton> => { 77 + const { ctx, params } = inputs; 78 + const viewer = params.hydrateCtx.viewer!; 79 + const res = await ctx.dataplane.stories.getArchive( 80 + viewer, 81 + params.limit, 82 + params.cursor, 83 + params.hydrateCtx.includeTakedowns || false, 84 + ); 85 + 86 + return { 87 + stories: res.stories.map((story) => story.uri), 88 + cursor: parseString(res.cursor), 89 + }; 90 + }; 91 + 92 + const hydration = async ( 93 + inputs: HydrationFnInput<Context, Params, Skeleton>, 94 + ): Promise<HydrationState> => { 95 + const { ctx, params, skeleton } = inputs; 96 + const authorDids = [...new Set(skeleton.stories.map((uri) => uriToDid(uri)))]; 97 + 98 + const [stories, actors] = await Promise.all([ 99 + ctx.hydrator.story.getArchivedStories( 100 + skeleton.stories, 101 + params.hydrateCtx.includeTakedowns || false, 102 + ), 103 + ctx.hydrator.actor.getActors(authorDids, params.hydrateCtx), 104 + ]); 105 + 106 + return { 107 + stories, 108 + actors, 109 + }; 110 + }; 111 + 112 + const rules = (inputs: RulesFnInput<Context, Params, Skeleton>): Skeleton => { 113 + const { skeleton, hydration } = inputs; 114 + const availableStories = skeleton.stories.filter((uri) => { 115 + return Boolean(hydration.stories?.get(uri)); 116 + }); 117 + return { stories: availableStories, cursor: skeleton.cursor }; 118 + }; 119 + 120 + const presentation = ( 121 + inputs: PresentationFnInput<Context, Params, Skeleton>, 122 + ): OutputSchema => { 123 + const { ctx, skeleton, hydration } = inputs; 124 + const storyViews = mapDefined(skeleton.stories, (uri) => { 125 + return ctx.views.story(uri, hydration); 126 + }); 127 + return { 128 + stories: storyViews, 129 + ...(skeleton.cursor && { cursor: skeleton.cursor }), 130 + }; 131 + }; 132 + 133 + type Context = AppContext; 134 + 135 + type Params = QueryParams & { 136 + hydrateCtx: HydrateCtx & { viewer: string }; 137 + limit: number; 138 + }; 139 + 140 + type Skeleton = { 141 + stories: string[]; 142 + cursor?: string; 143 + };
+4
data-plane/db/index.ts
··· 56 56 "Record", 57 57 models.recordSchema, 58 58 ), 59 + ArchivedRecord: this.connection.model<models.ArchivedRecordDocument>( 60 + "ArchivedRecord", 61 + models.archivedRecordSchema, 62 + ), 59 63 DuplicateRecord: this.connection.model<models.DuplicateRecordDocument>( 60 64 "DuplicateRecord", 61 65 models.duplicateRecordSchema,
+34 -2
data-plane/db/models.ts
··· 107 107 takedownRef: string; 108 108 invalidReplyRoot?: boolean; 109 109 } 110 + 111 + export interface ArchivedRecordDocument extends Document { 112 + uri: string; 113 + cid: string; 114 + did: string; 115 + collectionName: string; 116 + rkey: string; 117 + createdAt: string; 118 + indexedAt: string; 119 + json: string; 120 + archivedAt: string; 121 + deleteReason: "user_delete" | "takedown"; 122 + takedownRef?: string; 123 + } 110 124 export const recordSchema = new Schema<RecordDocument>({ 111 125 uri: { type: String, required: true, unique: true, index: true }, 112 126 cid: { type: String, required: true }, ··· 120 134 takedownRef: { type: String, required: false }, 121 135 invalidReplyRoot: { type: Boolean, required: false }, 122 136 }); 137 + 138 + export const archivedRecordSchema = new Schema<ArchivedRecordDocument>({ 139 + uri: { type: String, required: true, unique: true, index: true }, 140 + cid: { type: String, required: true }, 141 + did: { type: String, required: true, index: true }, 142 + collectionName: { type: String, required: true, index: true }, 143 + rkey: { type: String, required: true }, 144 + createdAt: { type: String, required: true }, 145 + indexedAt: { type: String, required: true }, 146 + json: { type: String, required: true }, 147 + archivedAt: { type: String, required: true }, 148 + deleteReason: { 149 + type: String, 150 + required: true, 151 + enum: ["user_delete", "takedown"], 152 + }, 153 + takedownRef: { type: String, required: false }, 154 + }) 155 + .index({ did: 1, collectionName: 1, indexedAt: -1 }); 123 156 124 157 // duplicate records 125 158 ··· 354 387 media: StoryMedia; 355 388 sound?: RecordRef; 356 389 labels?: Label[]; 357 - archived?: boolean; 358 390 } 359 391 export const storySchema = new Schema<StoryDocument>({ 360 392 ...authoredSchema, ··· 367 399 required: false, 368 400 }, 369 401 labels: { type: [Object], required: false, default: [] }, 370 - archived: { type: Boolean, required: true, default: false }, 371 402 }) 372 403 .index({ authorDid: 1, createdAt: -1 }); 373 404 ··· 661 692 662 693 export interface DatabaseModels { 663 694 Record: Model<RecordDocument>; 695 + ArchivedRecord: Model<ArchivedRecordDocument>; 664 696 DuplicateRecord: Model<DuplicateRecordDocument>; 665 697 Like: Model<LikeDocument>; 666 698 Post: Model<PostDocument>;
+4 -6
data-plane/indexing/plugins/story.ts
··· 27 27 tags: obj.tags || [], 28 28 createdAt: normalizeDatetimeAlways(obj.createdAt), 29 29 indexedAt: timestamp, 30 - archived: false, 31 30 }; 32 31 33 32 // Use findOneAndUpdate with upsert to handle potential duplicate key errors ··· 51 50 db: Database, 52 51 uri: AtUri, 53 52 ): Promise<IndexedStory | null> => { 54 - return await db.models.Story.findOneAndUpdate( 55 - { uri: uri.toString() }, 56 - { archived: true }, 57 - { new: true }, 58 - ); 53 + return await db.models.Story.findOneAndDelete({ 54 + uri: uri.toString(), 55 + }); 59 56 }; 60 57 61 58 const notifsForDelete = () => { ··· 73 70 insertFn, 74 71 findDuplicate, 75 72 deleteFn, 73 + archiveOnDelete: true, 76 74 notifsForInsert, 77 75 notifsForDelete, 78 76 });
+27 -2
data-plane/indexing/processor.ts
··· 30 30 replacedBy: S | null, 31 31 ) => { notifs: Notif[]; toDelete: string[] }; 32 32 updateAggregates?: (db: Database, obj: S) => Promise<void>; 33 + archiveOnDelete?: boolean; 33 34 }; 34 35 35 36 type Notif = { ··· 245 246 } 246 247 247 248 async deleteRecord(uri: AtUri, cascading = false) { 248 - await this.db.models.Record.deleteOne({ uri: uri.toString() }); 249 - await this.db.models.DuplicateRecord.deleteOne({ uri: uri.toString() }); 249 + const uriStr = uri.toString(); 250 + const record = await this.db.models.Record.findOne({ uri: uriStr }).lean(); 251 + 252 + if (record && this.params.archiveOnDelete) { 253 + const isTakedown = !!record.takedownRef; 254 + await this.db.models.ArchivedRecord.findOneAndUpdate( 255 + { uri: uriStr }, 256 + { 257 + uri: record.uri, 258 + cid: record.cid, 259 + did: record.did, 260 + collectionName: record.collectionName, 261 + rkey: record.rkey, 262 + createdAt: record.createdAt, 263 + indexedAt: record.indexedAt, 264 + json: record.json, 265 + archivedAt: new Date().toISOString(), 266 + deleteReason: isTakedown ? "takedown" : "user_delete", 267 + takedownRef: record.takedownRef || undefined, 268 + }, 269 + { upsert: true, new: true }, 270 + ); 271 + } 272 + 273 + await this.db.models.Record.deleteOne({ uri: uriStr }); 274 + await this.db.models.DuplicateRecord.deleteOne({ uri: uriStr }); 250 275 251 276 const deleted = await this.params.deleteFn(this.db, uri); 252 277 if (!deleted) return;
+48
data-plane/routes/records.ts
··· 60 60 return { records }; 61 61 } 62 62 63 + export async function getArchivedRecords( 64 + db: Database, 65 + uris: string[], 66 + collection?: string, 67 + ): Promise<{ 68 + records: Array<Record>; 69 + }> { 70 + const validUris = collection 71 + ? uris.filter((uri) => new AtUri(uri).collection === collection) 72 + : uris; 73 + 74 + const res = validUris.length 75 + ? await db.models.ArchivedRecord.find({ 76 + uri: { $in: validUris }, 77 + }) 78 + : []; 79 + 80 + const byUri = keyBy(res, "uri"); 81 + 82 + const records: Record[] = uris.map((uri) => { 83 + const row = byUri.get(uri); 84 + const createdAt = row?.createdAt 85 + ? new Date(row.createdAt).toISOString() 86 + : undefined; 87 + const indexedAt = row?.indexedAt 88 + ? new Date(row.indexedAt).toISOString() 89 + : undefined; 90 + 91 + return { 92 + record: row?.json ?? JSON.stringify(null), 93 + uri, 94 + cid: row?.cid, 95 + createdAt, 96 + indexedAt, 97 + sortedAt: compositeTime(createdAt, indexedAt), 98 + takenDown: !!row?.takedownRef, 99 + takedownRef: row?.takedownRef || undefined, 100 + }; 101 + }); 102 + 103 + return { records }; 104 + } 105 + 63 106 // Helper function to get post records with metadata 64 107 async function getPostRecords( 65 108 db: Database, ··· 157 200 158 201 async getStoryRecords(uris: string[]) { 159 202 const result = await getRecords(this.db, uris, ids.SoSprkStoryPost); 203 + return result; 204 + } 205 + 206 + async getArchivedStoryRecords(uris: string[]) { 207 + const result = await getArchivedRecords(this.db, uris, ids.SoSprkStoryPost); 160 208 return result; 161 209 } 162 210 }
+68 -13
data-plane/routes/stories.ts
··· 1 1 import { Database } from "../db/index.ts"; 2 2 import { TimeCidKeyset } from "../db/pagination.ts"; 3 3 import { compositeTime } from "../util.ts"; 4 + import { ids } from "../../lex/lexicons.ts"; 4 5 5 6 const STORIES_EXPIRY_HOURS = 24; 6 7 ··· 24 25 } 25 26 26 27 /** 27 - * Get active (non-archived, non-expired) stories by URIs 28 + * Get active (non-expired) stories by URIs 28 29 */ 29 30 async getStories(uris: string[]): Promise<StoryItem[]> { 30 31 if (!uris.length) return []; ··· 37 38 38 39 const stories = await this.db.models.Story.find({ 39 40 uri: { $in: uris }, 40 - archived: { $ne: true }, 41 41 indexedAt: { $gte: minDate }, 42 42 }).lean(); 43 43 ··· 47 47 authorDid: story.authorDid, 48 48 createdAt: story.createdAt, 49 49 indexedAt: story.indexedAt, 50 - archived: story.archived ?? false, 50 + archived: false, 51 51 sortAt: compositeTime(story.createdAt, story.indexedAt) || 52 52 story.createdAt, 53 53 })); ··· 75 75 ); 76 76 const minDate = twentyFourHoursAgo.toISOString(); 77 77 78 - // Build query with expiry filter (exclude archived stories) 78 + // Build query with expiry filter 79 79 const storiesQuery = this.db.models.Story.find({ 80 80 authorDid: { $in: timelineDids }, 81 - archived: { $ne: true }, 82 81 // Keep this nested to avoid merging with keyset cursor $or filter. 83 82 $and: [ 84 83 { ··· 110 109 authorDid: story.authorDid, 111 110 createdAt: story.createdAt, 112 111 indexedAt: story.indexedAt, 113 - archived: story.archived ?? false, 112 + archived: false, 114 113 sortAt: compositeTime(story.createdAt, story.indexedAt) || 115 114 story.createdAt, 116 115 })); 117 116 118 117 // Generate cursor from last item if we have more results 119 118 let nextCursor: string | undefined; 120 - if (hasMore && transformedStories.length > 0) { 121 - const lastStory = transformedStories[transformedStories.length - 1]; 122 - nextCursor = this.timeCidKeyset.pack({ 123 - primary: lastStory.sortAt, 124 - secondary: lastStory.cid, 125 - }); 119 + if (hasMore && resultStories.length > 0) { 120 + nextCursor = this.timeCidKeyset.packFromResult(resultStories); 121 + } 122 + 123 + return { 124 + stories: transformedStories, 125 + cursor: nextCursor, 126 + }; 127 + } 128 + 129 + /** 130 + * Get archived stories for an author 131 + */ 132 + async getArchive( 133 + actorDid: string, 134 + limit = 50, 135 + cursor?: string, 136 + includeTakedowns = false, 137 + ): Promise<{ stories: StoryItem[]; cursor?: string }> { 138 + const baseQuery: { 139 + did: string; 140 + collectionName: string; 141 + $or?: Array< 142 + { takedownRef?: { $exists: boolean } } | { takedownRef: string } 143 + >; 144 + } = { 145 + did: actorDid, 146 + collectionName: ids.SoSprkStoryPost, 147 + }; 148 + 149 + if (!includeTakedowns) { 150 + baseQuery.$or = [ 151 + { takedownRef: { $exists: false } }, 152 + { takedownRef: "" }, 153 + ]; 154 + } 155 + 156 + const storiesQuery = this.db.models.ArchivedRecord.find(baseQuery); 157 + 158 + const paginatedQuery = this.timeCidKeyset.paginate(storiesQuery, { 159 + limit: limit + 1, 160 + cursor, 161 + direction: "desc", 162 + }); 163 + 164 + const stories = await paginatedQuery.exec(); 165 + const hasMore = stories.length > limit; 166 + const resultStories = hasMore ? stories.slice(0, limit) : stories; 167 + 168 + const transformedStories: StoryItem[] = resultStories.map((story) => ({ 169 + uri: story.uri, 170 + cid: story.cid, 171 + authorDid: story.did, 172 + createdAt: story.createdAt, 173 + indexedAt: story.indexedAt, 174 + archived: true, 175 + sortAt: compositeTime(story.createdAt, story.indexedAt) || 176 + story.createdAt, 177 + })); 178 + 179 + let nextCursor: string | undefined; 180 + if (hasMore && resultStories.length > 0) { 181 + nextCursor = this.timeCidKeyset.packFromResult(resultStories); 126 182 } 127 183 128 184 return { ··· 144 200 ); 145 201 146 202 return stories.filter((story) => { 147 - if (story.archived) return false; 148 203 if (ownerDid && story.authorDid === ownerDid) return true; 149 204 const storyDate = new Date(story.indexedAt); 150 205 return storyDate >= twentyFourHoursAgo;
+20
hydration/story.ts
··· 29 29 ); 30 30 }, base); 31 31 } 32 + 33 + async getArchivedStories( 34 + uris: string[], 35 + includeTakedowns = false, 36 + given = new HydrationMap<Story>(), 37 + ): Promise<Stories> { 38 + const [have, need] = split(uris, (uri) => given.has(uri)); 39 + const base = have.reduce( 40 + (acc, uri) => acc.set(uri, given.get(uri) ?? null), 41 + new HydrationMap<Story>(), 42 + ); 43 + if (!need.length) return base; 44 + 45 + const res = await this.dataplane.records.getArchivedStoryRecords(need); 46 + 47 + return need.reduce((acc, uri, i) => { 48 + const record = parseRecord<StoryRecord>(res.records[i], includeTakedowns); 49 + return acc.set(uri, record ?? null); 50 + }, base); 51 + } 32 52 }
+13
lex/index.ts
··· 216 216 import type * as SoSprkActorGetProfiles from "./types/so/sprk/actor/getProfiles.ts"; 217 217 import type * as SoSprkActorGetPreferences from "./types/so/sprk/actor/getPreferences.ts"; 218 218 import type * as SoSprkStoryGetTimeline from "./types/so/sprk/story/getTimeline.ts"; 219 + import type * as SoSprkStoryGetArchive from "./types/so/sprk/story/getArchive.ts"; 219 220 import type * as SoSprkStoryGetStories from "./types/so/sprk/story/getStories.ts"; 220 221 import type * as SoSprkLabelerGetServices from "./types/so/sprk/labeler/getServices.ts"; 221 222 import type * as ComAtprotoTempDereferenceScope from "./types/com/atproto/temp/dereferenceScope.ts"; ··· 3313 3314 >, 3314 3315 ) { 3315 3316 const nsid = "so.sprk.story.getTimeline"; // @ts-ignore - dynamically generated 3317 + return this._server.xrpc.method(nsid, cfg); 3318 + } 3319 + 3320 + getArchive<A extends Auth = void>( 3321 + cfg: MethodConfigOrHandler< 3322 + A, 3323 + SoSprkStoryGetArchive.QueryParams, 3324 + SoSprkStoryGetArchive.HandlerInput, 3325 + SoSprkStoryGetArchive.HandlerOutput 3326 + >, 3327 + ) { 3328 + const nsid = "so.sprk.story.getArchive"; // @ts-ignore - dynamically generated 3316 3329 return this._server.xrpc.method(nsid, cfg); 3317 3330 } 3318 3331
+47
lex/lexicons.ts
··· 20753 20753 }, 20754 20754 }, 20755 20755 }, 20756 + "SoSprkStoryGetArchive": { 20757 + "lexicon": 1, 20758 + "id": "so.sprk.story.getArchive", 20759 + "defs": { 20760 + "main": { 20761 + "type": "query", 20762 + "description": 20763 + "Get archived stories for the requesting account. Requires auth.", 20764 + "parameters": { 20765 + "type": "params", 20766 + "properties": { 20767 + "limit": { 20768 + "type": "integer", 20769 + "minimum": 1, 20770 + "maximum": 100, 20771 + "default": 50, 20772 + }, 20773 + "cursor": { 20774 + "type": "string", 20775 + }, 20776 + }, 20777 + }, 20778 + "output": { 20779 + "encoding": "application/json", 20780 + "schema": { 20781 + "type": "object", 20782 + "required": [ 20783 + "stories", 20784 + ], 20785 + "properties": { 20786 + "cursor": { 20787 + "type": "string", 20788 + }, 20789 + "stories": { 20790 + "type": "array", 20791 + "items": { 20792 + "type": "ref", 20793 + "ref": "lex:so.sprk.story.defs#storyView", 20794 + }, 20795 + }, 20796 + }, 20797 + }, 20798 + }, 20799 + }, 20800 + }, 20801 + }, 20756 20802 "SoSprkStoryGetStories": { 20757 20803 "lexicon": 1, 20758 20804 "id": "so.sprk.story.getStories", ··· 26889 26935 SoSprkActorProfile: "so.sprk.actor.profile", 26890 26936 SoSprkStoryDefs: "so.sprk.story.defs", 26891 26937 SoSprkStoryGetTimeline: "so.sprk.story.getTimeline", 26938 + SoSprkStoryGetArchive: "so.sprk.story.getArchive", 26892 26939 SoSprkStoryGetStories: "so.sprk.story.getStories", 26893 26940 SoSprkStoryPost: "so.sprk.story.post", 26894 26941 SoSprkLabelerDefs: "so.sprk.labeler.defs",
+30
lex/types/so/sprk/story/getArchive.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import type * as SoSprkStoryDefs from "./defs.ts"; 5 + 6 + export type QueryParams = { 7 + limit: number; 8 + cursor?: string; 9 + }; 10 + export type InputSchema = undefined; 11 + 12 + export interface OutputSchema { 13 + cursor?: string; 14 + stories: (SoSprkStoryDefs.StoryView)[]; 15 + } 16 + 17 + export type HandlerInput = void; 18 + 19 + export interface HandlerSuccess { 20 + encoding: "application/json"; 21 + body: OutputSchema; 22 + headers?: { [key: string]: string }; 23 + } 24 + 25 + export interface HandlerError { 26 + status: number; 27 + message?: string; 28 + } 29 + 30 + export type HandlerOutput = HandlerError | HandlerSuccess;
+36
lexicons/so/sprk/story/getArchive.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "so.sprk.story.getArchive", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Get archived stories for the requesting account. Requires auth.", 8 + "parameters": { 9 + "type": "params", 10 + "properties": { 11 + "limit": { 12 + "type": "integer", 13 + "minimum": 1, 14 + "maximum": 100, 15 + "default": 50 16 + }, 17 + "cursor": { "type": "string" } 18 + } 19 + }, 20 + "output": { 21 + "encoding": "application/json", 22 + "schema": { 23 + "type": "object", 24 + "required": ["stories"], 25 + "properties": { 26 + "cursor": { "type": "string" }, 27 + "stories": { 28 + "type": "array", 29 + "items": { "type": "ref", "ref": "so.sprk.story.defs#storyView" } 30 + } 31 + } 32 + } 33 + } 34 + } 35 + } 36 + }
+255 -17
tests/stories_test.ts
··· 1 1 import { assertEquals } from "@std/assert"; 2 2 import { createTestContext, TEST_USERS } from "./util.ts"; 3 3 4 + const VALID_BLOB_CID = 5 + "bafyreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"; 6 + 7 + const storyRecordJson = (createdAt: string) => { 8 + return JSON.stringify({ 9 + $type: "so.sprk.story.post", 10 + createdAt, 11 + media: { 12 + $type: "so.sprk.media.image", 13 + image: { 14 + $type: "blob", 15 + ref: { $link: VALID_BLOB_CID }, 16 + mimeType: "image/jpeg", 17 + size: 250000, 18 + }, 19 + alt: "Archived story image", 20 + aspectRatio: { width: 1080, height: 1920 }, 21 + }, 22 + }); 23 + }; 24 + 4 25 Deno.test({ 5 26 name: "Stories", 6 27 sanitizeOps: false, 7 28 sanitizeResources: false, 8 29 fn: async (t) => { 9 30 await t.step( 10 - "getStories excludes archived and expired stories", 31 + "getStories excludes expired stories", 11 32 async () => { 12 33 const { ctx, cleanup } = await createTestContext({ 13 34 actors: false, 14 35 profiles: false, 15 36 posts: false, 16 37 replies: false, 17 - stories: true, 38 + stories: false, 18 39 likes: false, 19 40 reposts: false, 20 41 follows: false, ··· 27 48 }); 28 49 29 50 try { 30 - const archivedUri = `at://${TEST_USERS[0].did}/app.sprk.story/story1`; 31 - const expiredUri = `at://${TEST_USERS[2].did}/app.sprk.story/story2`; 32 - const activeUri = `at://${TEST_USERS[1].did}/app.sprk.story/story3`; 33 - 34 - await ctx.db.models.Story.findOneAndUpdate( 35 - { uri: archivedUri }, 36 - { archived: true }, 37 - ); 51 + const expiredUri = `at://${ 52 + TEST_USERS[2].did 53 + }/so.sprk.story.post/story2`; 54 + const activeUri = `at://${ 55 + TEST_USERS[1].did 56 + }/so.sprk.story.post/story3`; 38 57 39 58 const expiredDate = new Date(); 40 59 expiredDate.setHours(expiredDate.getHours() - 25); 41 - await ctx.db.models.Story.findOneAndUpdate( 42 - { uri: expiredUri }, 43 - { indexedAt: expiredDate.toISOString() }, 44 - ); 60 + await ctx.db.models.Story.create({ 61 + uri: expiredUri, 62 + cid: "bafyreihivhfhv6rh4x4a4znkqrvqwp5xw4xvqjqstory2", 63 + authorDid: TEST_USERS[2].did, 64 + createdAt: expiredDate.toISOString(), 65 + indexedAt: expiredDate.toISOString(), 66 + media: { 67 + $type: "so.sprk.media.image", 68 + image: { 69 + $type: "blob", 70 + ref: { 71 + $link: VALID_BLOB_CID, 72 + }, 73 + alt: "Expired story image", 74 + aspectRatio: { width: 1080, height: 1920 }, 75 + mimeType: "image/jpeg", 76 + size: 250000, 77 + }, 78 + alt: "Expired story image", 79 + aspectRatio: { width: 1080, height: 1920 }, 80 + }, 81 + labels: [], 82 + }); 45 83 46 84 await ctx.db.models.Story.create({ 47 85 uri: activeUri, ··· 50 88 createdAt: new Date().toISOString(), 51 89 indexedAt: new Date().toISOString(), 52 90 media: { 53 - $type: "app.sprk.story#imageMedia", 91 + $type: "so.sprk.media.image", 54 92 image: { 55 93 $type: "blob", 56 94 ref: { 57 - $link: "bafyreihivhfhv6rh4x4a4znkqrvqwp5xw4xvqjqstoryimg3", 95 + $link: VALID_BLOB_CID, 58 96 }, 59 97 alt: "Active story image", 60 98 aspectRatio: { width: 1080, height: 1920 }, ··· 66 104 }); 67 105 68 106 const stories = await ctx.dataplane.stories.getStories([ 69 - archivedUri, 70 107 expiredUri, 71 108 activeUri, 72 109 ]); ··· 79 116 } 80 117 }, 81 118 ); 119 + 120 + await t.step( 121 + "getArchive excludes takedown archived stories by default", 122 + async () => { 123 + const { ctx, cleanup } = await createTestContext({ 124 + actors: false, 125 + profiles: false, 126 + posts: false, 127 + replies: false, 128 + stories: false, 129 + likes: false, 130 + reposts: false, 131 + follows: false, 132 + blocks: false, 133 + audio: false, 134 + generators: false, 135 + preferences: false, 136 + records: false, 137 + actorSync: false, 138 + }); 139 + 140 + try { 141 + const now = new Date().toISOString(); 142 + const archivedUri = `at://${ 143 + TEST_USERS[0].did 144 + }/so.sprk.story.post/story1`; 145 + const takedownArchivedUri = `at://${ 146 + TEST_USERS[0].did 147 + }/so.sprk.story.post/story-takedown`; 148 + const otherAuthorArchivedUri = `at://${ 149 + TEST_USERS[2].did 150 + }/so.sprk.story.post/story2`; 151 + 152 + await ctx.db.models.ArchivedRecord.create({ 153 + uri: archivedUri, 154 + cid: "bafyreihivhfhv6rh4x4a4znkqrvqwp5xw4xvqjqstory1", 155 + did: TEST_USERS[0].did, 156 + collectionName: "so.sprk.story.post", 157 + rkey: "story1", 158 + createdAt: now, 159 + indexedAt: now, 160 + json: storyRecordJson(now), 161 + archivedAt: now, 162 + deleteReason: "user_delete", 163 + }); 164 + await ctx.db.models.ArchivedRecord.create({ 165 + uri: takedownArchivedUri, 166 + cid: "bafyreihivhfhv6rh4x4a4znkqrvqwp5xw4xvqjqstorytakedown", 167 + did: TEST_USERS[0].did, 168 + collectionName: "so.sprk.story.post", 169 + rkey: "story-takedown", 170 + createdAt: now, 171 + indexedAt: now, 172 + json: storyRecordJson(now), 173 + archivedAt: now, 174 + deleteReason: "takedown", 175 + takedownRef: "SPRK-TAKEDOWN-1", 176 + }); 177 + await ctx.db.models.ArchivedRecord.create({ 178 + uri: otherAuthorArchivedUri, 179 + cid: "bafyreihivhfhv6rh4x4a4znkqrvqwp5xw4xvqjqstory2", 180 + did: TEST_USERS[2].did, 181 + collectionName: "so.sprk.story.post", 182 + rkey: "story2", 183 + createdAt: now, 184 + indexedAt: now, 185 + json: storyRecordJson(now), 186 + archivedAt: now, 187 + deleteReason: "user_delete", 188 + }); 189 + 190 + const res = await ctx.dataplane.stories.getArchive( 191 + TEST_USERS[0].did, 192 + 10, 193 + ); 194 + 195 + assertEquals(res.stories.length, 1); 196 + assertEquals(res.stories[0].uri, archivedUri); 197 + assertEquals(res.stories[0].archived, true); 198 + assertEquals(res.cursor, undefined); 199 + 200 + const resIncludingTakedowns = await ctx.dataplane.stories.getArchive( 201 + TEST_USERS[0].did, 202 + 10, 203 + undefined, 204 + true, 205 + ); 206 + assertEquals(resIncludingTakedowns.stories.length, 2); 207 + } finally { 208 + await cleanup(); 209 + } 210 + }, 211 + ); 212 + 213 + await t.step( 214 + "getArchivedStories hydrates from archived records", 215 + async () => { 216 + const { ctx, cleanup } = await createTestContext({ 217 + actors: false, 218 + profiles: false, 219 + posts: false, 220 + replies: false, 221 + stories: false, 222 + likes: false, 223 + reposts: false, 224 + follows: false, 225 + blocks: false, 226 + audio: false, 227 + generators: false, 228 + preferences: false, 229 + records: false, 230 + actorSync: false, 231 + }); 232 + 233 + try { 234 + const now = new Date().toISOString(); 235 + const archivedUri = `at://${ 236 + TEST_USERS[0].did 237 + }/so.sprk.story.post/story1`; 238 + const missingUri = `at://${ 239 + TEST_USERS[2].did 240 + }/so.sprk.story.post/story2`; 241 + 242 + await ctx.db.models.ArchivedRecord.create({ 243 + uri: archivedUri, 244 + cid: "bafyreihivhfhv6rh4x4a4znkqrvqwp5xw4xvqjqstory3", 245 + did: TEST_USERS[0].did, 246 + collectionName: "so.sprk.story.post", 247 + rkey: "story1", 248 + createdAt: now, 249 + indexedAt: now, 250 + json: storyRecordJson(now), 251 + archivedAt: now, 252 + deleteReason: "user_delete", 253 + }); 254 + 255 + const hydrated = await ctx.hydrator.story.getArchivedStories([ 256 + archivedUri, 257 + missingUri, 258 + ]); 259 + 260 + assertEquals(Boolean(hydrated.get(archivedUri)), true); 261 + assertEquals(hydrated.get(missingUri), null); 262 + } finally { 263 + await cleanup(); 264 + } 265 + }, 266 + ); 267 + 268 + await t.step("getArchivedStories applies takedown filtering", async () => { 269 + const { ctx, cleanup } = await createTestContext({ 270 + actors: false, 271 + profiles: false, 272 + posts: false, 273 + replies: false, 274 + stories: false, 275 + likes: false, 276 + reposts: false, 277 + follows: false, 278 + blocks: false, 279 + audio: false, 280 + generators: false, 281 + preferences: false, 282 + records: false, 283 + actorSync: false, 284 + }); 285 + 286 + try { 287 + const now = new Date().toISOString(); 288 + const takedownUri = `at://${ 289 + TEST_USERS[0].did 290 + }/so.sprk.story.post/story-takedown`; 291 + 292 + await ctx.db.models.ArchivedRecord.create({ 293 + uri: takedownUri, 294 + cid: "bafyreihivhfhv6rh4x4a4znkqrvqwp5xw4xvqjqstory4", 295 + did: TEST_USERS[0].did, 296 + collectionName: "so.sprk.story.post", 297 + rkey: "story-takedown", 298 + createdAt: now, 299 + indexedAt: now, 300 + json: storyRecordJson(now), 301 + archivedAt: now, 302 + deleteReason: "user_delete", 303 + takedownRef: "SPRK-TAKEDOWN-1", 304 + }); 305 + 306 + const hidden = await ctx.hydrator.story.getArchivedStories([ 307 + takedownUri, 308 + ]); 309 + assertEquals(hidden.get(takedownUri), null); 310 + 311 + const included = await ctx.hydrator.story.getArchivedStories( 312 + [takedownUri], 313 + true, 314 + ); 315 + assertEquals(Boolean(included.get(takedownUri)), true); 316 + } finally { 317 + await cleanup(); 318 + } 319 + }); 82 320 }, 83 321 });
+4
tests/util.ts
··· 141 141 "Record", 142 142 models.recordSchema, 143 143 ), 144 + ArchivedRecord: connection.model<models.ArchivedRecordDocument>( 145 + "ArchivedRecord", 146 + models.archivedRecordSchema, 147 + ), 144 148 DuplicateRecord: connection.model<models.DuplicateRecordDocument>( 145 149 "DuplicateRecord", 146 150 models.duplicateRecordSchema,