diff --git a/CHANGES.md b/CHANGES.md index 31c2ca97..f640a533 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -131,6 +131,18 @@ To be released. before the Unix epoch (January 1, 1970), which caused `uuidv7()` to receive a negative timestamp. [[#67], [#466]] + - Fixed `min_id` handling on `GET /api/v1/timelines/public`, + `GET /api/v1/timelines/home`, `GET /api/v1/timelines/list/:list_id`, and + `GET /api/v1/timelines/tag/:hashtag` to follow Mastodon's pagination + semantics: `min_id` now returns the posts *immediately* newer than the + cursor (rather than the most recent posts above it), so gap-loading + clients such as SubwayTooter can converge on arbitrarily large gaps. + `since_id` is now honoured on these endpoints as well, and `min_id` + takes precedence when both are supplied. Timeline responses also + include a `rel="prev"` entry in the `Link` header alongside the existing + `rel="next"` entry, so clients no longer have to guess which cursor + parameter to use. [[#479], [#482]] + - Upgraded Fedify to 2.2.1. [FEP-044f]: https://w3id.org/fep/044f @@ -143,6 +155,8 @@ To be released. [#460]: https://github.com/fedify-dev/hollo/pull/460 [#466]: https://github.com/fedify-dev/hollo/pull/466 [#467]: https://github.com/fedify-dev/hollo/pull/467 +[#479]: https://github.com/fedify-dev/hollo/issues/479 +[#482]: https://github.com/fedify-dev/hollo/pull/482 Version 0.8.4 diff --git a/src/api/v1/timelines.test.ts b/src/api/v1/timelines.test.ts index c3c6491b..c89891c0 100644 --- a/src/api/v1/timelines.test.ts +++ b/src/api/v1/timelines.test.ts @@ -384,3 +384,134 @@ describe.sequential("/api/v1/timelines/home", () => { expect(json[0].content).toContain(quotedPostUrl); }); }); + +describe.sequential("/api/v1/timelines/public (pagination)", () => { + let owner: Awaited>; + let client: Awaited>; + let accessToken: Awaited>; + // postIds[0] is the oldest; postIds[24] is the newest. + let postIds: Uuid[]; + + beforeEach(async () => { + await cleanDatabase(); + + owner = await createAccount(); + client = await createOAuthApplication({ scopes: ["read:statuses"] }); + accessToken = await getAccessToken(client, owner, ["read:statuses"]); + + postIds = []; + for (let i = 0; i < 25; i++) { + const id = uuidv7(); + postIds.push(id); + await db.insert(posts).values({ + id, + iri: `https://hollo.test/@hollo/${id}`, + type: "Note", + accountId: owner.id, + visibility: "public", + content: `Post ${i}`, + contentHtml: `

Post ${i}

`, + published: new Date(), + }); + } + }); + + async function fetchTimeline(qs: string): Promise { + return await app.request(`/api/v1/timelines/public${qs}`, { + method: "GET", + headers: { + authorization: bearerAuthorization(accessToken), + }, + }); + } + + it("returns the newest posts with bidirectional Link headers", async () => { + expect.assertions(6); + + const response = await fetchTimeline("?limit=10"); + expect(response.status).toBe(200); + + const json = (await response.json()) as { id: string }[]; + expect(json).toHaveLength(10); + expect(json[0].id).toBe(postIds[24]); + expect(json[9].id).toBe(postIds[15]); + + const link = response.headers.get("Link") ?? ""; + expect(link).toContain(`max_id=${postIds[15]}>; rel="next"`); + expect(link).toContain(`min_id=${postIds[24]}>; rel="prev"`); + }); + + it("walks up a large gap with min_id (Mastodon gap-loading)", async () => { + expect.assertions(4); + + // Cursor sits 19 posts below the top. With limit=5, gap-loading must + // return the 5 posts *immediately* above the cursor — postIds[6..10] — + // ordered newest-first. Naïve `since_id`-style logic would instead + // return postIds[24..20] and the gap would never close. + const response = await fetchTimeline(`?limit=5&min_id=${postIds[5]}`); + expect(response.status).toBe(200); + + const json = (await response.json()) as { id: string }[]; + expect(json.map((p) => p.id)).toEqual([ + postIds[10], + postIds[9], + postIds[8], + postIds[7], + postIds[6], + ]); + + // The rel="prev" cursor must point at the newest returned post so a + // follow-up request continues walking up the gap. + const link = response.headers.get("Link") ?? ""; + expect(link).toContain(`min_id=${postIds[10]}>; rel="prev"`); + expect(link).toContain(`max_id=${postIds[6]}>; rel="next"`); + }); + + it("returns the newest posts above the cursor when only since_id is set", async () => { + expect.assertions(2); + + const response = await fetchTimeline(`?limit=5&since_id=${postIds[5]}`); + expect(response.status).toBe(200); + + const json = (await response.json()) as { id: string }[]; + expect(json.map((p) => p.id)).toEqual([ + postIds[24], + postIds[23], + postIds[22], + postIds[21], + postIds[20], + ]); + }); + + it("lets min_id win over since_id when both are supplied", async () => { + expect.assertions(1); + + const response = await fetchTimeline( + `?limit=5&min_id=${postIds[5]}&since_id=${postIds[20]}`, + ); + const json = (await response.json()) as { id: string }[]; + expect(json.map((p) => p.id)).toEqual([ + postIds[10], + postIds[9], + postIds[8], + postIds[7], + postIds[6], + ]); + }); + + it("drops conflicting cursors when generating Link headers", async () => { + expect.assertions(2); + + // Passing every cursor at once should not propagate into the next/prev + // links — each link must contain exactly one of max_id/min_id and no + // stale since_id. + const response = await fetchTimeline( + `?limit=5&max_id=${postIds[24]}&min_id=${postIds[0]}&since_id=${postIds[10]}`, + ); + const link = response.headers.get("Link") ?? ""; + expect(link).not.toContain("since_id="); + // rel="next" carries max_id only; rel="prev" carries min_id only. + const matches = link.match(/(max_id|min_id|since_id)=/g) ?? []; + expect(matches).toHaveLength(2); + }); +}); diff --git a/src/api/v1/timelines.ts b/src/api/v1/timelines.ts index 9ef2f336..5c57fb16 100644 --- a/src/api/v1/timelines.ts +++ b/src/api/v1/timelines.ts @@ -1,6 +1,7 @@ import { zValidator } from "@hono/zod-validator"; import { and, + asc, desc, eq, gt, @@ -40,7 +41,7 @@ import { posts, timelinePosts, } from "../../schema"; -import { isUuid, uuid } from "../../uuid"; +import { isUuid, uuid, type Uuid } from "../../uuid"; import { getApprovedFollowingAccountIds, postAccountIdInArray, @@ -71,6 +72,44 @@ export const publicTimelineQuerySchema = timelineQuerySchema.extend({ .transform((v) => v === "true"), }); +// Mastodon pagination semantics shared by every timeline endpoint: +// `min_id` returns the posts *immediately* newer than the cursor (ASC + +// reverse to newest-first); `since_id` is applied only when `min_id` is +// absent and keeps the normal DESC order. +function resolveTimelineCursor(query: { min_id?: Uuid; since_id?: Uuid }): { + useMinId: boolean; + lowerBound: Uuid | undefined; +} { + return { + useMinId: query.min_id != null, + lowerBound: query.min_id ?? query.since_id, + }; +} + +// Build Mastodon-compatible bidirectional pagination Link header for a +// timeline response. `timeline` must be ordered newest-first (DESC by id). +function buildTimelineLinkHeader( + requestUrl: string, + timeline: readonly { id: Uuid }[], + limit: number, +): { Link: string } | undefined { + if (timeline.length === 0) return undefined; + const baseUrl = new URL(requestUrl); + baseUrl.searchParams.delete("max_id"); + baseUrl.searchParams.delete("min_id"); + baseUrl.searchParams.delete("since_id"); + const linkParts: string[] = []; + if (timeline.length >= limit) { + const next = new URL(baseUrl); + next.searchParams.set("max_id", timeline[timeline.length - 1].id); + linkParts.push(`<${next.href}>; rel="next"`); + } + const prev = new URL(baseUrl); + prev.searchParams.set("min_id", timeline[0].id); + linkParts.push(`<${prev.href}>; rel="prev"`); + return { Link: linkParts.join(", ") }; +} + app.get( "/public", withAccountOwner, @@ -78,6 +117,7 @@ app.get( async (c) => { const owner = c.get("accountOwner"); const query = c.req.valid("query"); + const { useMinId, lowerBound } = resolveTimelineCursor(query); const timeline = await db.query.posts.findMany({ where: and( eq(posts.visibility, "public"), @@ -178,20 +218,17 @@ app.get( ), ), query.max_id == null ? undefined : lt(posts.id, query.max_id), - query.min_id == null ? undefined : gt(posts.id, query.min_id), + lowerBound == null ? undefined : gt(posts.id, lowerBound), ), with: getPostRelations(owner.id), - orderBy: [desc(posts.id)], + orderBy: [useMinId ? asc(posts.id) : desc(posts.id)], limit: query.limit, }); - const nextMaxId = - timeline.length >= query.limit ? timeline[timeline.length - 1].id : null; - const nextLink = nextMaxId == null ? undefined : new URL(c.req.url); - nextLink?.searchParams.set("max_id", nextMaxId ?? ""); + if (useMinId) timeline.reverse(); return c.json( timeline.map((p) => serializePost(p, owner, c.req.url)), 200, - nextLink == null ? undefined : { Link: `<${nextLink.href}>; rel="next"` }, + buildTimelineLinkHeader(c.req.url, timeline, query.limit), ); }, ); @@ -204,6 +241,7 @@ app.get( async (c) => { const owner = c.get("accountOwner"); const query = c.req.valid("query"); + const { useMinId, lowerBound } = resolveTimelineCursor(query); let timeline: Parameters[0][]; if (TIMELINE_INBOXES) { timeline = await db.query.posts.findMany({ @@ -219,12 +257,16 @@ app.get( query.max_id == null ? undefined : lt(timelinePosts.postId, query.max_id), - query.min_id == null + lowerBound == null ? undefined - : gt(timelinePosts.postId, query.min_id), + : gt(timelinePosts.postId, lowerBound), ), ) - .orderBy(desc(timelinePosts.postId)) + .orderBy( + useMinId + ? asc(timelinePosts.postId) + : desc(timelinePosts.postId), + ) .limit(Math.min(TIMELINE_INBOX_LIMIT, query.limit)), ), // Hide future posts @@ -313,7 +355,7 @@ app.get( ), ), with: getPostRelations(owner.id), - orderBy: [desc(posts.id)], + orderBy: [useMinId ? asc(posts.id) : desc(posts.id)], limit: query.limit, }); } else { @@ -460,21 +502,18 @@ app.get( ), ), query.max_id == null ? undefined : lt(posts.id, query.max_id), - query.min_id == null ? undefined : gt(posts.id, query.min_id), + lowerBound == null ? undefined : gt(posts.id, lowerBound), ), with: getPostRelations(owner.id), - orderBy: [desc(posts.id)], + orderBy: [useMinId ? asc(posts.id) : desc(posts.id)], limit: query.limit, }); } - const nextMaxId = - timeline.length >= query.limit ? timeline[timeline.length - 1].id : null; - const nextLink = nextMaxId == null ? undefined : new URL(c.req.url); - nextLink?.searchParams.set("max_id", nextMaxId ?? ""); + if (useMinId) timeline.reverse(); return c.json( timeline.map((p) => serializePost(p, owner, c.req.url)), 200, - nextLink == null ? undefined : { Link: `<${nextLink.href}>; rel="next"` }, + buildTimelineLinkHeader(c.req.url, timeline, query.limit), ); }, ); @@ -494,6 +533,7 @@ app.get( where: and(eq(lists.id, listId), eq(lists.accountOwnerId, owner.id)), }); if (list == null) return c.json({ error: "Record not found" }, 404); + const { useMinId, lowerBound } = resolveTimelineCursor(query); let timeline: Parameters[0][]; if (TIMELINE_INBOXES) { timeline = await db.query.posts.findMany({ @@ -509,12 +549,14 @@ app.get( query.max_id == null ? undefined : lt(listPosts.postId, query.max_id), - query.min_id == null + lowerBound == null ? undefined - : gt(listPosts.postId, query.min_id), + : gt(listPosts.postId, lowerBound), ), ) - .orderBy(desc(listPosts.postId)) + .orderBy( + useMinId ? asc(listPosts.postId) : desc(listPosts.postId), + ) .limit(Math.min(TIMELINE_INBOX_LIMIT, query.limit)), ), // Hide future posts @@ -603,7 +645,7 @@ app.get( ), ), with: getPostRelations(owner.id), - orderBy: [desc(posts.id)], + orderBy: [useMinId ? asc(posts.id) : desc(posts.id)], limit: query.limit, }); } else { @@ -730,21 +772,18 @@ app.get( ), ), query.max_id == null ? undefined : lt(posts.id, query.max_id), - query.min_id == null ? undefined : gt(posts.id, query.min_id), + lowerBound == null ? undefined : gt(posts.id, lowerBound), ), with: getPostRelations(owner.id), - orderBy: [desc(posts.id)], + orderBy: [useMinId ? asc(posts.id) : desc(posts.id)], limit: query.limit, }); } - const nextMaxId = - timeline.length >= query.limit ? timeline[timeline.length - 1].id : null; - const nextLink = nextMaxId == null ? undefined : new URL(c.req.url); - nextLink?.searchParams.set("max_id", nextMaxId ?? ""); + if (useMinId) timeline.reverse(); return c.json( timeline.map((p) => serializePost(p, owner, c.req.url)), 200, - nextLink == null ? undefined : { Link: `<${nextLink.href}>; rel="next"` }, + buildTimelineLinkHeader(c.req.url, timeline, query.limit), ); }, ); @@ -760,6 +799,7 @@ app.get( const query = c.req.valid("query"); const hashtag = `#${c.req.param("hashtag")}`; const followingAccountIds = await getApprovedFollowingAccountIds(owner.id); + const { useMinId, lowerBound } = resolveTimelineCursor(query); const timeline = await db.query.posts.findMany({ where: and( or( @@ -830,20 +870,17 @@ app.get( .where(eq(blocks.blockedAccountId, owner.id)), ), query.max_id == null ? undefined : lt(posts.id, query.max_id), - query.min_id == null ? undefined : gt(posts.id, query.min_id), + lowerBound == null ? undefined : gt(posts.id, lowerBound), ), with: getPostRelations(owner.id), - orderBy: [desc(posts.id)], + orderBy: [useMinId ? asc(posts.id) : desc(posts.id)], limit: query.limit, }); - const nextMaxId = - timeline.length >= query.limit ? timeline[timeline.length - 1].id : null; - const nextLink = nextMaxId == null ? undefined : new URL(c.req.url); - nextLink?.searchParams.set("max_id", nextMaxId ?? ""); + if (useMinId) timeline.reverse(); return c.json( timeline.map((p) => serializePost(p, owner, c.req.url)), 200, - nextLink == null ? undefined : { Link: `<${nextLink.href}>; rel="next"` }, + buildTimelineLinkHeader(c.req.url, timeline, query.limit), ); }, );