diff --git a/api/index.ts b/api/index.ts index f08ce2f..8a7471f 100644 --- a/api/index.ts +++ b/api/index.ts @@ -21,6 +21,7 @@ import getRecord from "./com/atproto/repo/getRecord.ts"; import resolveHandle from "./com/atproto/identity/resolveHandle.ts"; import getStories from "./so/sprk/story/getStories.ts"; import getStoriesTimeline from "./so/sprk/story/getTimeline.ts"; +import getStoriesArchive from "./so/sprk/story/getArchive.ts"; import getProfiles from "./so/sprk/actor/getProfiles.ts"; import searchPosts from "./so/sprk/feed/searchPosts.ts"; import getActorAudios from "./so/sprk/sound/getActorAudios.ts"; @@ -60,6 +61,7 @@ export default function (server: Server, ctx: AppContext) { resolveHandle(server, ctx); getStories(server, ctx); getStoriesTimeline(server, ctx); + getStoriesArchive(server, ctx); searchPosts(server, ctx); getActorAudios(server, ctx); getTrendingAudios(server, ctx); diff --git a/api/so/sprk/story/getArchive.ts b/api/so/sprk/story/getArchive.ts new file mode 100644 index 0000000..064a2e5 --- /dev/null +++ b/api/so/sprk/story/getArchive.ts @@ -0,0 +1,143 @@ +import { InvalidRequestError } from "@atp/xrpc-server"; +import { mapDefined } from "@atp/common"; +import { AppContext } from "../../../../context.ts"; +import { HydrateCtx, HydrationState } from "../../../../hydration/index.ts"; +import { parseString } from "../../../../hydration/util.ts"; +import { Server } from "../../../../lex/index.ts"; +import { + OutputSchema, + QueryParams, +} from "../../../../lex/types/so/sprk/story/getArchive.ts"; +import { + createPipeline, + HydrationFnInput, + PresentationFnInput, + RulesFnInput, + SkeletonFnInput, +} from "../../../../pipeline.ts"; +import { uriToDid } from "../../../../utils/uris.ts"; +import { resHeaders } from "../../../util.ts"; + +const MAX_LIMIT = 100; +const DEFAULT_LIMIT = 50; + +export default function (server: Server, ctx: AppContext) { + const getArchive = createPipeline(skeleton, hydration, rules, presentation); + server.so.sprk.story.getArchive({ + auth: ctx.authVerifier.standard, + handler: async ({ params, auth, req }) => { + const { includeTakedowns } = ctx.authVerifier.parseCreds(auth); + const viewer = auth.credentials.iss; + const labelers = ctx.reqLabelers(req); + const hydrateCtx = await ctx.hydrator.createContext({ + viewer, + labelers, + includeTakedowns, + }); + + const { limit: limitParam = DEFAULT_LIMIT, cursor } = params; + const limit = typeof limitParam === "string" + ? parseInt(limitParam, 10) + : limitParam; + + if (isNaN(limit) || limit < 1 || limit > MAX_LIMIT) { + throw new InvalidRequestError( + `Invalid limit: must be between 1 and ${MAX_LIMIT}`, + ); + } + + const [result, repoRev] = await Promise.all([ + getArchive( + { + ...params, + limit, + cursor, + hydrateCtx: hydrateCtx.copy({ viewer, includeTakedowns }), + }, + ctx, + ), + ctx.hydrator.actor.getRepoRevSafe(viewer), + ]); + + return { + encoding: "application/json", + body: result, + headers: resHeaders({ + repoRev, + labelers: hydrateCtx.labelers, + }), + }; + }, + }); +} + +const skeleton = async ( + inputs: SkeletonFnInput, +): Promise => { + const { ctx, params } = inputs; + const viewer = params.hydrateCtx.viewer!; + const res = await ctx.dataplane.stories.getArchive( + viewer, + params.limit, + params.cursor, + params.hydrateCtx.includeTakedowns || false, + ); + + return { + stories: res.stories.map((story) => story.uri), + cursor: parseString(res.cursor), + }; +}; + +const hydration = async ( + inputs: HydrationFnInput, +): Promise => { + const { ctx, params, skeleton } = inputs; + const authorDids = [...new Set(skeleton.stories.map((uri) => uriToDid(uri)))]; + + const [stories, actors] = await Promise.all([ + ctx.hydrator.story.getArchivedStories( + skeleton.stories, + params.hydrateCtx.includeTakedowns || false, + ), + ctx.hydrator.actor.getActors(authorDids, params.hydrateCtx), + ]); + + return { + stories, + actors, + }; +}; + +const rules = (inputs: RulesFnInput): Skeleton => { + const { skeleton, hydration } = inputs; + const availableStories = skeleton.stories.filter((uri) => { + return Boolean(hydration.stories?.get(uri)); + }); + return { stories: availableStories, cursor: skeleton.cursor }; +}; + +const presentation = ( + inputs: PresentationFnInput, +): OutputSchema => { + const { ctx, skeleton, hydration } = inputs; + const storyViews = mapDefined(skeleton.stories, (uri) => { + return ctx.views.story(uri, hydration); + }); + return { + stories: storyViews, + ...(skeleton.cursor && { cursor: skeleton.cursor }), + }; +}; + +type Context = AppContext; + +type Params = QueryParams & { + hydrateCtx: HydrateCtx & { viewer: string }; + limit: number; +}; + +type Skeleton = { + stories: string[]; + cursor?: string; +}; diff --git a/data-plane/db/index.ts b/data-plane/db/index.ts index 4607dd5..edef037 100644 --- a/data-plane/db/index.ts +++ b/data-plane/db/index.ts @@ -56,6 +56,10 @@ export class Database { "Record", models.recordSchema, ), + ArchivedRecord: this.connection.model( + "ArchivedRecord", + models.archivedRecordSchema, + ), DuplicateRecord: this.connection.model( "DuplicateRecord", models.duplicateRecordSchema, diff --git a/data-plane/db/models.ts b/data-plane/db/models.ts index 11283bc..cd94fbb 100644 --- a/data-plane/db/models.ts +++ b/data-plane/db/models.ts @@ -107,6 +107,20 @@ export interface RecordDocument extends Document { takedownRef: string; invalidReplyRoot?: boolean; } + +export interface ArchivedRecordDocument extends Document { + uri: string; + cid: string; + did: string; + collectionName: string; + rkey: string; + createdAt: string; + indexedAt: string; + json: string; + archivedAt: string; + deleteReason: "user_delete" | "takedown"; + takedownRef?: string; +} export const recordSchema = new Schema({ uri: { type: String, required: true, unique: true, index: true }, cid: { type: String, required: true }, @@ -121,6 +135,25 @@ export const recordSchema = new Schema({ invalidReplyRoot: { type: Boolean, required: false }, }); +export const archivedRecordSchema = new Schema({ + uri: { type: String, required: true, unique: true, index: true }, + cid: { type: String, required: true }, + did: { type: String, required: true, index: true }, + collectionName: { type: String, required: true, index: true }, + rkey: { type: String, required: true }, + createdAt: { type: String, required: true }, + indexedAt: { type: String, required: true }, + json: { type: String, required: true }, + archivedAt: { type: String, required: true }, + deleteReason: { + type: String, + required: true, + enum: ["user_delete", "takedown"], + }, + takedownRef: { type: String, required: false }, +}) + .index({ did: 1, collectionName: 1, indexedAt: -1 }); + // duplicate records export interface DuplicateRecordDocument extends Document { @@ -354,7 +387,6 @@ export interface StoryDocument extends AuthoredDocument { media: StoryMedia; sound?: RecordRef; labels?: Label[]; - archived?: boolean; } export const storySchema = new Schema({ ...authoredSchema, @@ -367,7 +399,6 @@ export const storySchema = new Schema({ required: false, }, labels: { type: [Object], required: false, default: [] }, - archived: { type: Boolean, required: true, default: false }, }) .index({ authorDid: 1, createdAt: -1 }); @@ -661,6 +692,7 @@ export const pushTokenSchema = new Schema({ export interface DatabaseModels { Record: Model; + ArchivedRecord: Model; DuplicateRecord: Model; Like: Model; Post: Model; diff --git a/data-plane/indexing/plugins/story.ts b/data-plane/indexing/plugins/story.ts index e49835a..b37e68e 100644 --- a/data-plane/indexing/plugins/story.ts +++ b/data-plane/indexing/plugins/story.ts @@ -27,7 +27,6 @@ const insertFn = async ( tags: obj.tags || [], createdAt: normalizeDatetimeAlways(obj.createdAt), indexedAt: timestamp, - archived: false, }; // Use findOneAndUpdate with upsert to handle potential duplicate key errors @@ -51,11 +50,9 @@ const deleteFn = async ( db: Database, uri: AtUri, ): Promise => { - return await db.models.Story.findOneAndUpdate( - { uri: uri.toString() }, - { archived: true }, - { new: true }, - ); + return await db.models.Story.findOneAndDelete({ + uri: uri.toString(), + }); }; const notifsForDelete = () => { @@ -73,6 +70,7 @@ export const makePlugin = ( insertFn, findDuplicate, deleteFn, + archiveOnDelete: true, notifsForInsert, notifsForDelete, }); diff --git a/data-plane/indexing/processor.ts b/data-plane/indexing/processor.ts index fb585ab..c45a070 100644 --- a/data-plane/indexing/processor.ts +++ b/data-plane/indexing/processor.ts @@ -30,6 +30,7 @@ type RecordProcessorParams = { replacedBy: S | null, ) => { notifs: Notif[]; toDelete: string[] }; updateAggregates?: (db: Database, obj: S) => Promise; + archiveOnDelete?: boolean; }; type Notif = { @@ -245,8 +246,32 @@ export class RecordProcessor { } async deleteRecord(uri: AtUri, cascading = false) { - await this.db.models.Record.deleteOne({ uri: uri.toString() }); - await this.db.models.DuplicateRecord.deleteOne({ uri: uri.toString() }); + const uriStr = uri.toString(); + const record = await this.db.models.Record.findOne({ uri: uriStr }).lean(); + + if (record && this.params.archiveOnDelete) { + const isTakedown = !!record.takedownRef; + await this.db.models.ArchivedRecord.findOneAndUpdate( + { uri: uriStr }, + { + uri: record.uri, + cid: record.cid, + did: record.did, + collectionName: record.collectionName, + rkey: record.rkey, + createdAt: record.createdAt, + indexedAt: record.indexedAt, + json: record.json, + archivedAt: new Date().toISOString(), + deleteReason: isTakedown ? "takedown" : "user_delete", + takedownRef: record.takedownRef || undefined, + }, + { upsert: true, new: true }, + ); + } + + await this.db.models.Record.deleteOne({ uri: uriStr }); + await this.db.models.DuplicateRecord.deleteOne({ uri: uriStr }); const deleted = await this.params.deleteFn(this.db, uri); if (!deleted) return; diff --git a/data-plane/routes/records.ts b/data-plane/routes/records.ts index 3acfa4b..a391306 100644 --- a/data-plane/routes/records.ts +++ b/data-plane/routes/records.ts @@ -60,6 +60,49 @@ export async function getRecords( return { records }; } +export async function getArchivedRecords( + db: Database, + uris: string[], + collection?: string, +): Promise<{ + records: Array; +}> { + const validUris = collection + ? uris.filter((uri) => new AtUri(uri).collection === collection) + : uris; + + const res = validUris.length + ? await db.models.ArchivedRecord.find({ + uri: { $in: validUris }, + }) + : []; + + const byUri = keyBy(res, "uri"); + + const records: Record[] = uris.map((uri) => { + const row = byUri.get(uri); + const createdAt = row?.createdAt + ? new Date(row.createdAt).toISOString() + : undefined; + const indexedAt = row?.indexedAt + ? new Date(row.indexedAt).toISOString() + : undefined; + + return { + record: row?.json ?? JSON.stringify(null), + uri, + cid: row?.cid, + createdAt, + indexedAt, + sortedAt: compositeTime(createdAt, indexedAt), + takenDown: !!row?.takedownRef, + takedownRef: row?.takedownRef || undefined, + }; + }); + + return { records }; +} + // Helper function to get post records with metadata async function getPostRecords( db: Database, @@ -159,4 +202,9 @@ export class Records { const result = await getRecords(this.db, uris, ids.SoSprkStoryPost); return result; } + + async getArchivedStoryRecords(uris: string[]) { + const result = await getArchivedRecords(this.db, uris, ids.SoSprkStoryPost); + return result; + } } diff --git a/data-plane/routes/stories.ts b/data-plane/routes/stories.ts index fd3ad33..48f1687 100644 --- a/data-plane/routes/stories.ts +++ b/data-plane/routes/stories.ts @@ -1,6 +1,7 @@ import { Database } from "../db/index.ts"; import { TimeCidKeyset } from "../db/pagination.ts"; import { compositeTime } from "../util.ts"; +import { ids } from "../../lex/lexicons.ts"; const STORIES_EXPIRY_HOURS = 24; @@ -24,7 +25,7 @@ export class Stories { } /** - * Get active (non-archived, non-expired) stories by URIs + * Get active (non-expired) stories by URIs */ async getStories(uris: string[]): Promise { if (!uris.length) return []; @@ -37,7 +38,6 @@ export class Stories { const stories = await this.db.models.Story.find({ uri: { $in: uris }, - archived: { $ne: true }, indexedAt: { $gte: minDate }, }).lean(); @@ -47,7 +47,7 @@ export class Stories { authorDid: story.authorDid, createdAt: story.createdAt, indexedAt: story.indexedAt, - archived: story.archived ?? false, + archived: false, sortAt: compositeTime(story.createdAt, story.indexedAt) || story.createdAt, })); @@ -75,10 +75,9 @@ export class Stories { ); const minDate = twentyFourHoursAgo.toISOString(); - // Build query with expiry filter (exclude archived stories) + // Build query with expiry filter const storiesQuery = this.db.models.Story.find({ authorDid: { $in: timelineDids }, - archived: { $ne: true }, // Keep this nested to avoid merging with keyset cursor $or filter. $and: [ { @@ -110,19 +109,76 @@ export class Stories { authorDid: story.authorDid, createdAt: story.createdAt, indexedAt: story.indexedAt, - archived: story.archived ?? false, + archived: false, sortAt: compositeTime(story.createdAt, story.indexedAt) || story.createdAt, })); // Generate cursor from last item if we have more results let nextCursor: string | undefined; - if (hasMore && transformedStories.length > 0) { - const lastStory = transformedStories[transformedStories.length - 1]; - nextCursor = this.timeCidKeyset.pack({ - primary: lastStory.sortAt, - secondary: lastStory.cid, - }); + if (hasMore && resultStories.length > 0) { + nextCursor = this.timeCidKeyset.packFromResult(resultStories); + } + + return { + stories: transformedStories, + cursor: nextCursor, + }; + } + + /** + * Get archived stories for an author + */ + async getArchive( + actorDid: string, + limit = 50, + cursor?: string, + includeTakedowns = false, + ): Promise<{ stories: StoryItem[]; cursor?: string }> { + const baseQuery: { + did: string; + collectionName: string; + $or?: Array< + { takedownRef?: { $exists: boolean } } | { takedownRef: string } + >; + } = { + did: actorDid, + collectionName: ids.SoSprkStoryPost, + }; + + if (!includeTakedowns) { + baseQuery.$or = [ + { takedownRef: { $exists: false } }, + { takedownRef: "" }, + ]; + } + + const storiesQuery = this.db.models.ArchivedRecord.find(baseQuery); + + const paginatedQuery = this.timeCidKeyset.paginate(storiesQuery, { + limit: limit + 1, + cursor, + direction: "desc", + }); + + const stories = await paginatedQuery.exec(); + const hasMore = stories.length > limit; + const resultStories = hasMore ? stories.slice(0, limit) : stories; + + const transformedStories: StoryItem[] = resultStories.map((story) => ({ + uri: story.uri, + cid: story.cid, + authorDid: story.did, + createdAt: story.createdAt, + indexedAt: story.indexedAt, + archived: true, + sortAt: compositeTime(story.createdAt, story.indexedAt) || + story.createdAt, + })); + + let nextCursor: string | undefined; + if (hasMore && resultStories.length > 0) { + nextCursor = this.timeCidKeyset.packFromResult(resultStories); } return { @@ -144,7 +200,6 @@ export class Stories { ); return stories.filter((story) => { - if (story.archived) return false; if (ownerDid && story.authorDid === ownerDid) return true; const storyDate = new Date(story.indexedAt); return storyDate >= twentyFourHoursAgo; diff --git a/hydration/story.ts b/hydration/story.ts index 8c151bf..b25e7cb 100644 --- a/hydration/story.ts +++ b/hydration/story.ts @@ -29,4 +29,24 @@ export class StoryHydrator { ); }, base); } + + async getArchivedStories( + uris: string[], + includeTakedowns = false, + given = new HydrationMap(), + ): Promise { + const [have, need] = split(uris, (uri) => given.has(uri)); + const base = have.reduce( + (acc, uri) => acc.set(uri, given.get(uri) ?? null), + new HydrationMap(), + ); + if (!need.length) return base; + + const res = await this.dataplane.records.getArchivedStoryRecords(need); + + return need.reduce((acc, uri, i) => { + const record = parseRecord(res.records[i], includeTakedowns); + return acc.set(uri, record ?? null); + }, base); + } } diff --git a/lex/index.ts b/lex/index.ts index 23c9e74..40062cc 100644 --- a/lex/index.ts +++ b/lex/index.ts @@ -216,6 +216,7 @@ import type * as SoSprkActorSearchActors from "./types/so/sprk/actor/searchActor import type * as SoSprkActorGetProfiles from "./types/so/sprk/actor/getProfiles.ts"; import type * as SoSprkActorGetPreferences from "./types/so/sprk/actor/getPreferences.ts"; import type * as SoSprkStoryGetTimeline from "./types/so/sprk/story/getTimeline.ts"; +import type * as SoSprkStoryGetArchive from "./types/so/sprk/story/getArchive.ts"; import type * as SoSprkStoryGetStories from "./types/so/sprk/story/getStories.ts"; import type * as SoSprkLabelerGetServices from "./types/so/sprk/labeler/getServices.ts"; import type * as ComAtprotoTempDereferenceScope from "./types/com/atproto/temp/dereferenceScope.ts"; @@ -3316,6 +3317,18 @@ export class SoSprkStoryNS { return this._server.xrpc.method(nsid, cfg); } + getArchive( + cfg: MethodConfigOrHandler< + A, + SoSprkStoryGetArchive.QueryParams, + SoSprkStoryGetArchive.HandlerInput, + SoSprkStoryGetArchive.HandlerOutput + >, + ) { + const nsid = "so.sprk.story.getArchive"; // @ts-ignore - dynamically generated + return this._server.xrpc.method(nsid, cfg); + } + getStories( cfg: MethodConfigOrHandler< A, diff --git a/lex/lexicons.ts b/lex/lexicons.ts index 1694d27..6300d2a 100644 --- a/lex/lexicons.ts +++ b/lex/lexicons.ts @@ -20753,6 +20753,52 @@ export const schemaDict = { }, }, }, + "SoSprkStoryGetArchive": { + "lexicon": 1, + "id": "so.sprk.story.getArchive", + "defs": { + "main": { + "type": "query", + "description": + "Get archived stories for the requesting account. Requires auth.", + "parameters": { + "type": "params", + "properties": { + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50, + }, + "cursor": { + "type": "string", + }, + }, + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": [ + "stories", + ], + "properties": { + "cursor": { + "type": "string", + }, + "stories": { + "type": "array", + "items": { + "type": "ref", + "ref": "lex:so.sprk.story.defs#storyView", + }, + }, + }, + }, + }, + }, + }, + }, "SoSprkStoryGetStories": { "lexicon": 1, "id": "so.sprk.story.getStories", @@ -26889,6 +26935,7 @@ export const ids = { SoSprkActorProfile: "so.sprk.actor.profile", SoSprkStoryDefs: "so.sprk.story.defs", SoSprkStoryGetTimeline: "so.sprk.story.getTimeline", + SoSprkStoryGetArchive: "so.sprk.story.getArchive", SoSprkStoryGetStories: "so.sprk.story.getStories", SoSprkStoryPost: "so.sprk.story.post", SoSprkLabelerDefs: "so.sprk.labeler.defs", diff --git a/lex/types/so/sprk/story/getArchive.ts b/lex/types/so/sprk/story/getArchive.ts new file mode 100644 index 0000000..5c7feaa --- /dev/null +++ b/lex/types/so/sprk/story/getArchive.ts @@ -0,0 +1,30 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import type * as SoSprkStoryDefs from "./defs.ts"; + +export type QueryParams = { + limit: number; + cursor?: string; +}; +export type InputSchema = undefined; + +export interface OutputSchema { + cursor?: string; + stories: (SoSprkStoryDefs.StoryView)[]; +} + +export type HandlerInput = void; + +export interface HandlerSuccess { + encoding: "application/json"; + body: OutputSchema; + headers?: { [key: string]: string }; +} + +export interface HandlerError { + status: number; + message?: string; +} + +export type HandlerOutput = HandlerError | HandlerSuccess; diff --git a/lexicons/so/sprk/story/getArchive.json b/lexicons/so/sprk/story/getArchive.json new file mode 100644 index 0000000..eef8777 --- /dev/null +++ b/lexicons/so/sprk/story/getArchive.json @@ -0,0 +1,36 @@ +{ + "lexicon": 1, + "id": "so.sprk.story.getArchive", + "defs": { + "main": { + "type": "query", + "description": "Get archived stories for the requesting account. Requires auth.", + "parameters": { + "type": "params", + "properties": { + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + }, + "cursor": { "type": "string" } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["stories"], + "properties": { + "cursor": { "type": "string" }, + "stories": { + "type": "array", + "items": { "type": "ref", "ref": "so.sprk.story.defs#storyView" } + } + } + } + } + } + } +} diff --git a/tests/stories_test.ts b/tests/stories_test.ts index 8cf8fd0..0e36268 100644 --- a/tests/stories_test.ts +++ b/tests/stories_test.ts @@ -1,20 +1,41 @@ import { assertEquals } from "@std/assert"; import { createTestContext, TEST_USERS } from "./util.ts"; +const VALID_BLOB_CID = + "bafyreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"; + +const storyRecordJson = (createdAt: string) => { + return JSON.stringify({ + $type: "so.sprk.story.post", + createdAt, + media: { + $type: "so.sprk.media.image", + image: { + $type: "blob", + ref: { $link: VALID_BLOB_CID }, + mimeType: "image/jpeg", + size: 250000, + }, + alt: "Archived story image", + aspectRatio: { width: 1080, height: 1920 }, + }, + }); +}; + Deno.test({ name: "Stories", sanitizeOps: false, sanitizeResources: false, fn: async (t) => { await t.step( - "getStories excludes archived and expired stories", + "getStories excludes expired stories", async () => { const { ctx, cleanup } = await createTestContext({ actors: false, profiles: false, posts: false, replies: false, - stories: true, + stories: false, likes: false, reposts: false, follows: false, @@ -27,21 +48,38 @@ Deno.test({ }); try { - const archivedUri = `at://${TEST_USERS[0].did}/app.sprk.story/story1`; - const expiredUri = `at://${TEST_USERS[2].did}/app.sprk.story/story2`; - const activeUri = `at://${TEST_USERS[1].did}/app.sprk.story/story3`; - - await ctx.db.models.Story.findOneAndUpdate( - { uri: archivedUri }, - { archived: true }, - ); + const expiredUri = `at://${ + TEST_USERS[2].did + }/so.sprk.story.post/story2`; + const activeUri = `at://${ + TEST_USERS[1].did + }/so.sprk.story.post/story3`; const expiredDate = new Date(); expiredDate.setHours(expiredDate.getHours() - 25); - await ctx.db.models.Story.findOneAndUpdate( - { uri: expiredUri }, - { indexedAt: expiredDate.toISOString() }, - ); + await ctx.db.models.Story.create({ + uri: expiredUri, + cid: "bafyreihivhfhv6rh4x4a4znkqrvqwp5xw4xvqjqstory2", + authorDid: TEST_USERS[2].did, + createdAt: expiredDate.toISOString(), + indexedAt: expiredDate.toISOString(), + media: { + $type: "so.sprk.media.image", + image: { + $type: "blob", + ref: { + $link: VALID_BLOB_CID, + }, + alt: "Expired story image", + aspectRatio: { width: 1080, height: 1920 }, + mimeType: "image/jpeg", + size: 250000, + }, + alt: "Expired story image", + aspectRatio: { width: 1080, height: 1920 }, + }, + labels: [], + }); await ctx.db.models.Story.create({ uri: activeUri, @@ -50,11 +88,11 @@ Deno.test({ createdAt: new Date().toISOString(), indexedAt: new Date().toISOString(), media: { - $type: "app.sprk.story#imageMedia", + $type: "so.sprk.media.image", image: { $type: "blob", ref: { - $link: "bafyreihivhfhv6rh4x4a4znkqrvqwp5xw4xvqjqstoryimg3", + $link: VALID_BLOB_CID, }, alt: "Active story image", aspectRatio: { width: 1080, height: 1920 }, @@ -66,7 +104,6 @@ Deno.test({ }); const stories = await ctx.dataplane.stories.getStories([ - archivedUri, expiredUri, activeUri, ]); @@ -79,5 +116,206 @@ Deno.test({ } }, ); + + await t.step( + "getArchive excludes takedown archived stories by default", + async () => { + const { ctx, cleanup } = await createTestContext({ + actors: false, + profiles: false, + posts: false, + replies: false, + stories: false, + likes: false, + reposts: false, + follows: false, + blocks: false, + audio: false, + generators: false, + preferences: false, + records: false, + actorSync: false, + }); + + try { + const now = new Date().toISOString(); + const archivedUri = `at://${ + TEST_USERS[0].did + }/so.sprk.story.post/story1`; + const takedownArchivedUri = `at://${ + TEST_USERS[0].did + }/so.sprk.story.post/story-takedown`; + const otherAuthorArchivedUri = `at://${ + TEST_USERS[2].did + }/so.sprk.story.post/story2`; + + await ctx.db.models.ArchivedRecord.create({ + uri: archivedUri, + cid: "bafyreihivhfhv6rh4x4a4znkqrvqwp5xw4xvqjqstory1", + did: TEST_USERS[0].did, + collectionName: "so.sprk.story.post", + rkey: "story1", + createdAt: now, + indexedAt: now, + json: storyRecordJson(now), + archivedAt: now, + deleteReason: "user_delete", + }); + await ctx.db.models.ArchivedRecord.create({ + uri: takedownArchivedUri, + cid: "bafyreihivhfhv6rh4x4a4znkqrvqwp5xw4xvqjqstorytakedown", + did: TEST_USERS[0].did, + collectionName: "so.sprk.story.post", + rkey: "story-takedown", + createdAt: now, + indexedAt: now, + json: storyRecordJson(now), + archivedAt: now, + deleteReason: "takedown", + takedownRef: "SPRK-TAKEDOWN-1", + }); + await ctx.db.models.ArchivedRecord.create({ + uri: otherAuthorArchivedUri, + cid: "bafyreihivhfhv6rh4x4a4znkqrvqwp5xw4xvqjqstory2", + did: TEST_USERS[2].did, + collectionName: "so.sprk.story.post", + rkey: "story2", + createdAt: now, + indexedAt: now, + json: storyRecordJson(now), + archivedAt: now, + deleteReason: "user_delete", + }); + + const res = await ctx.dataplane.stories.getArchive( + TEST_USERS[0].did, + 10, + ); + + assertEquals(res.stories.length, 1); + assertEquals(res.stories[0].uri, archivedUri); + assertEquals(res.stories[0].archived, true); + assertEquals(res.cursor, undefined); + + const resIncludingTakedowns = await ctx.dataplane.stories.getArchive( + TEST_USERS[0].did, + 10, + undefined, + true, + ); + assertEquals(resIncludingTakedowns.stories.length, 2); + } finally { + await cleanup(); + } + }, + ); + + await t.step( + "getArchivedStories hydrates from archived records", + async () => { + const { ctx, cleanup } = await createTestContext({ + actors: false, + profiles: false, + posts: false, + replies: false, + stories: false, + likes: false, + reposts: false, + follows: false, + blocks: false, + audio: false, + generators: false, + preferences: false, + records: false, + actorSync: false, + }); + + try { + const now = new Date().toISOString(); + const archivedUri = `at://${ + TEST_USERS[0].did + }/so.sprk.story.post/story1`; + const missingUri = `at://${ + TEST_USERS[2].did + }/so.sprk.story.post/story2`; + + await ctx.db.models.ArchivedRecord.create({ + uri: archivedUri, + cid: "bafyreihivhfhv6rh4x4a4znkqrvqwp5xw4xvqjqstory3", + did: TEST_USERS[0].did, + collectionName: "so.sprk.story.post", + rkey: "story1", + createdAt: now, + indexedAt: now, + json: storyRecordJson(now), + archivedAt: now, + deleteReason: "user_delete", + }); + + const hydrated = await ctx.hydrator.story.getArchivedStories([ + archivedUri, + missingUri, + ]); + + assertEquals(Boolean(hydrated.get(archivedUri)), true); + assertEquals(hydrated.get(missingUri), null); + } finally { + await cleanup(); + } + }, + ); + + await t.step("getArchivedStories applies takedown filtering", async () => { + const { ctx, cleanup } = await createTestContext({ + actors: false, + profiles: false, + posts: false, + replies: false, + stories: false, + likes: false, + reposts: false, + follows: false, + blocks: false, + audio: false, + generators: false, + preferences: false, + records: false, + actorSync: false, + }); + + try { + const now = new Date().toISOString(); + const takedownUri = `at://${ + TEST_USERS[0].did + }/so.sprk.story.post/story-takedown`; + + await ctx.db.models.ArchivedRecord.create({ + uri: takedownUri, + cid: "bafyreihivhfhv6rh4x4a4znkqrvqwp5xw4xvqjqstory4", + did: TEST_USERS[0].did, + collectionName: "so.sprk.story.post", + rkey: "story-takedown", + createdAt: now, + indexedAt: now, + json: storyRecordJson(now), + archivedAt: now, + deleteReason: "user_delete", + takedownRef: "SPRK-TAKEDOWN-1", + }); + + const hidden = await ctx.hydrator.story.getArchivedStories([ + takedownUri, + ]); + assertEquals(hidden.get(takedownUri), null); + + const included = await ctx.hydrator.story.getArchivedStories( + [takedownUri], + true, + ); + assertEquals(Boolean(included.get(takedownUri)), true); + } finally { + await cleanup(); + } + }); }, }); diff --git a/tests/util.ts b/tests/util.ts index b915a6f..7f04e30 100644 --- a/tests/util.ts +++ b/tests/util.ts @@ -141,6 +141,10 @@ export async function createTestDatabase( "Record", models.recordSchema, ), + ArchivedRecord: connection.model( + "ArchivedRecord", + models.archivedRecordSchema, + ), DuplicateRecord: connection.model( "DuplicateRecord", models.duplicateRecordSchema,