Skip to content

Commit 0ebf4ad

Browse files
authored
Merge pull request #482 from dahlia/feature/gap-loading
2 parents 6815a82 + b5a5582 commit 0ebf4ad

3 files changed

Lines changed: 219 additions & 37 deletions

File tree

CHANGES.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,18 @@ To be released.
131131
before the Unix epoch (January 1, 1970), which caused `uuidv7()` to
132132
receive a negative timestamp. [[#67], [#466]]
133133

134+
- Fixed `min_id` handling on `GET /api/v1/timelines/public`,
135+
`GET /api/v1/timelines/home`, `GET /api/v1/timelines/list/:list_id`, and
136+
`GET /api/v1/timelines/tag/:hashtag` to follow Mastodon's pagination
137+
semantics: `min_id` now returns the posts *immediately* newer than the
138+
cursor (rather than the most recent posts above it), so gap-loading
139+
clients such as SubwayTooter can converge on arbitrarily large gaps.
140+
`since_id` is now honoured on these endpoints as well, and `min_id`
141+
takes precedence when both are supplied. Timeline responses also
142+
include a `rel="prev"` entry in the `Link` header alongside the existing
143+
`rel="next"` entry, so clients no longer have to guess which cursor
144+
parameter to use. [[#479], [#482]]
145+
134146
- Upgraded Fedify to 2.2.1.
135147

136148
[FEP-044f]: https://w3id.org/fep/044f
@@ -143,6 +155,8 @@ To be released.
143155
[#460]: https://github.com/fedify-dev/hollo/pull/460
144156
[#466]: https://github.com/fedify-dev/hollo/pull/466
145157
[#467]: https://github.com/fedify-dev/hollo/pull/467
158+
[#479]: https://github.com/fedify-dev/hollo/issues/479
159+
[#482]: https://github.com/fedify-dev/hollo/pull/482
146160

147161

148162
Version 0.8.4

src/api/v1/timelines.test.ts

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,3 +384,134 @@ describe.sequential("/api/v1/timelines/home", () => {
384384
expect(json[0].content).toContain(quotedPostUrl);
385385
});
386386
});
387+
388+
describe.sequential("/api/v1/timelines/public (pagination)", () => {
389+
let owner: Awaited<ReturnType<typeof createAccount>>;
390+
let client: Awaited<ReturnType<typeof createOAuthApplication>>;
391+
let accessToken: Awaited<ReturnType<typeof getAccessToken>>;
392+
// postIds[0] is the oldest; postIds[24] is the newest.
393+
let postIds: Uuid[];
394+
395+
beforeEach(async () => {
396+
await cleanDatabase();
397+
398+
owner = await createAccount();
399+
client = await createOAuthApplication({ scopes: ["read:statuses"] });
400+
accessToken = await getAccessToken(client, owner, ["read:statuses"]);
401+
402+
postIds = [];
403+
for (let i = 0; i < 25; i++) {
404+
const id = uuidv7();
405+
postIds.push(id);
406+
await db.insert(posts).values({
407+
id,
408+
iri: `https://hollo.test/@hollo/${id}`,
409+
type: "Note",
410+
accountId: owner.id,
411+
visibility: "public",
412+
content: `Post ${i}`,
413+
contentHtml: `<p>Post ${i}</p>`,
414+
published: new Date(),
415+
});
416+
}
417+
});
418+
419+
async function fetchTimeline(qs: string): Promise<Response> {
420+
return await app.request(`/api/v1/timelines/public${qs}`, {
421+
method: "GET",
422+
headers: {
423+
authorization: bearerAuthorization(accessToken),
424+
},
425+
});
426+
}
427+
428+
it("returns the newest posts with bidirectional Link headers", async () => {
429+
expect.assertions(6);
430+
431+
const response = await fetchTimeline("?limit=10");
432+
expect(response.status).toBe(200);
433+
434+
const json = (await response.json()) as { id: string }[];
435+
expect(json).toHaveLength(10);
436+
expect(json[0].id).toBe(postIds[24]);
437+
expect(json[9].id).toBe(postIds[15]);
438+
439+
const link = response.headers.get("Link") ?? "";
440+
expect(link).toContain(`max_id=${postIds[15]}>; rel="next"`);
441+
expect(link).toContain(`min_id=${postIds[24]}>; rel="prev"`);
442+
});
443+
444+
it("walks up a large gap with min_id (Mastodon gap-loading)", async () => {
445+
expect.assertions(4);
446+
447+
// Cursor sits 19 posts below the top. With limit=5, gap-loading must
448+
// return the 5 posts *immediately* above the cursor — postIds[6..10] —
449+
// ordered newest-first. Naïve `since_id`-style logic would instead
450+
// return postIds[24..20] and the gap would never close.
451+
const response = await fetchTimeline(`?limit=5&min_id=${postIds[5]}`);
452+
expect(response.status).toBe(200);
453+
454+
const json = (await response.json()) as { id: string }[];
455+
expect(json.map((p) => p.id)).toEqual([
456+
postIds[10],
457+
postIds[9],
458+
postIds[8],
459+
postIds[7],
460+
postIds[6],
461+
]);
462+
463+
// The rel="prev" cursor must point at the newest returned post so a
464+
// follow-up request continues walking up the gap.
465+
const link = response.headers.get("Link") ?? "";
466+
expect(link).toContain(`min_id=${postIds[10]}>; rel="prev"`);
467+
expect(link).toContain(`max_id=${postIds[6]}>; rel="next"`);
468+
});
469+
470+
it("returns the newest posts above the cursor when only since_id is set", async () => {
471+
expect.assertions(2);
472+
473+
const response = await fetchTimeline(`?limit=5&since_id=${postIds[5]}`);
474+
expect(response.status).toBe(200);
475+
476+
const json = (await response.json()) as { id: string }[];
477+
expect(json.map((p) => p.id)).toEqual([
478+
postIds[24],
479+
postIds[23],
480+
postIds[22],
481+
postIds[21],
482+
postIds[20],
483+
]);
484+
});
485+
486+
it("lets min_id win over since_id when both are supplied", async () => {
487+
expect.assertions(1);
488+
489+
const response = await fetchTimeline(
490+
`?limit=5&min_id=${postIds[5]}&since_id=${postIds[20]}`,
491+
);
492+
const json = (await response.json()) as { id: string }[];
493+
expect(json.map((p) => p.id)).toEqual([
494+
postIds[10],
495+
postIds[9],
496+
postIds[8],
497+
postIds[7],
498+
postIds[6],
499+
]);
500+
});
501+
502+
it("drops conflicting cursors when generating Link headers", async () => {
503+
expect.assertions(2);
504+
505+
// Passing every cursor at once should not propagate into the next/prev
506+
// links — each link must contain exactly one of max_id/min_id and no
507+
// stale since_id.
508+
const response = await fetchTimeline(
509+
`?limit=5&max_id=${postIds[24]}&min_id=${postIds[0]}&since_id=${postIds[10]}`,
510+
);
511+
const link = response.headers.get("Link") ?? "";
512+
expect(link).not.toContain("since_id=");
513+
// rel="next" carries max_id only; rel="prev" carries min_id only.
514+
const matches = link.match(/(max_id|min_id|since_id)=/g) ?? [];
515+
expect(matches).toHaveLength(2);
516+
});
517+
});

0 commit comments

Comments
 (0)