Compare commits

..
1 Commits
Author SHA1 Message Date
dmg eb8ef18688 fix(suggestions): preserve one-item archived pagination
CI / validate (push) Successful in 8m20s
Release / release (push) Successful in 10m52s
2026-09-10 16:00:02 -04:00
4 changed files with 111 additions and 10 deletions
@@ -20,7 +20,17 @@ beforeEach(() => {
vi.stubGlobal("fetch", fetcher); vi.stubGlobal("fetch", fetcher);
fetcher.mockImplementation(async (url: string) => { fetcher.mockImplementation(async (url: string) => {
if (url.endsWith(`/channels/${forum}`)) return Response.json({ id: forum, guild_id: guild, type: 15, available_tags: [] }); if (url.endsWith(`/channels/${forum}`)) return Response.json({ id: forum, guild_id: guild, type: 15, available_tags: [] });
if (url.includes("/threads/archived/public")) return Response.json({ threads: [thread], has_more: true }); if (url.includes("/threads/archived/public")) {
const params = new URL(url).searchParams;
if (Number(params.get("limit")) < 2) return Response.json({ code: 50035, message: "Invalid Form Body", errors: { limit: { _errors: [{ code: "NUMBER_TYPE_MIN", message: "int value should be greater than or equal to 2." }] } } }, { status: 400 });
const newest = { ...thread, thread_metadata: { ...thread.thread_metadata, archived: true } };
const older = { ...newest, id: "100000000000000008", thread_metadata: { ...newest.thread_metadata, archive_timestamp: "2026-09-10T00:00:00.123455+00:00" } };
if (params.has("before")) {
expect(params.get("before")).toBe("2026-09-10T00:00:00.123456Z");
return Response.json({ threads: [older], has_more: false });
}
return Response.json({ threads: [newest, older], has_more: false });
}
if (url.includes("/threads/active")) return Response.json({ threads: [thread] }); if (url.includes("/threads/active")) return Response.json({ threads: [thread] });
if (url.endsWith(`/channels/${id}`)) return Response.json(thread); if (url.endsWith(`/channels/${id}`)) return Response.json(thread);
if (url.endsWith(`/messages/${id}`)) return Response.json(message); if (url.endsWith(`/messages/${id}`)) return Response.json(message);
@@ -48,7 +58,16 @@ it("documents the nullable deleted starter and precise archived cursor", async (
expect((await response.json()).originalPost).toBeNull(); expect((await response.json()).originalPost).toBeNull();
const archived = await list.GET(new Request("https://portal.example/api/suggestions?status=archived&limit=1")); const archived = await list.GET(new Request("https://portal.example/api/suggestions?status=archived&limit=1"));
await assertResponse("/api/suggestions", "get", archived); await assertResponse("/api/suggestions", "get", archived);
expect((await archived.json()).nextCursor).toBe("2026-09-10T00:00:00.123456Z"); expect(archived.status).toBe(200);
const first = await archived.json();
expect(first.items.map((item: { id: string }) => item.id)).toEqual([id]);
expect(first.nextCursor).toBe("2026-09-10T00:00:00.123456Z");
const terminal = await list.GET(new Request(`https://portal.example/api/suggestions?status=archived&limit=1&cursor=${encodeURIComponent(first.nextCursor)}`));
expect(terminal.status).toBe(200);
await assertResponse("/api/suggestions", "get", terminal);
const last = await terminal.json();
expect(last.items.map((item: { id: string }) => item.id)).toEqual(["100000000000000008"]);
expect(last.nextCursor).toBeNull();
}); });
it.each(routes)("documents Retry-After on $path upstream rate limits", async ({ path, route }) => { it.each(routes)("documents Retry-After on $path upstream rate limits", async ({ path, route }) => {
fetcher.mockImplementation(async () => Response.json({ retry_after: 2.1 }, { status: 429 })); fetcher.mockImplementation(async () => Response.json({ retry_after: 2.1 }, { status: 429 }));
+81 -4
View File
@@ -8,6 +8,9 @@ const thread = { id: threadId, guild_id: guildId, parent_id: forumId, type: 11,
function setup(responses: Record<string, unknown>) { function setup(responses: Record<string, unknown>) {
const fetcher = vi.fn<typeof fetch>(async (input) => { const fetcher = vi.fn<typeof fetch>(async (input) => {
const path = String(input).replace("https://discord.com/api/v10", ""); const path = String(input).replace("https://discord.com/api/v10", "");
if (path.includes("/threads/archived/public") && Number(new URL(String(input)).searchParams.get("limit")) < 2) {
return Response.json({ code: 50035, message: "Invalid Form Body", errors: { limit: { _errors: [{ code: "NUMBER_TYPE_MIN", message: "int value should be greater than or equal to 2." }] } } }, { status: 400 });
}
if (!(path in responses)) throw new Error(`Unexpected path: ${path}`); if (!(path in responses)) throw new Error(`Unexpected path: ${path}`);
const value = responses[path]; const value = responses[path];
return value instanceof Response ? value : Response.json(value); return value instanceof Response ? value : Response.json(value);
@@ -19,20 +22,94 @@ it("reads archived forum pages using Discord's archive timestamp cursor", async
const cursor = "2026-01-01T00:00:00Z"; const cursor = "2026-01-01T00:00:00Z";
const { client } = setup({ const { client } = setup({
[`/channels/${forumId}`]: forum, [`/channels/${forumId}`]: forum,
[`/channels/${forumId}/threads/archived/public?limit=1`]: { threads: [{ ...thread, thread_metadata: { ...thread.thread_metadata, archived: true } }], has_more: true }, [`/channels/${forumId}/threads/archived/public?limit=2`]: { threads: [{ ...thread, thread_metadata: { ...thread.thread_metadata, archived: true } }], has_more: true },
[`/channels/${forumId}/threads/archived/public?limit=1&before=${encodeURIComponent(cursor)}`]: { threads: [], has_more: false }, [`/channels/${forumId}/threads/archived/public?limit=2&before=${encodeURIComponent(cursor)}`]: { threads: [], has_more: false },
}); });
expect(await client.list({ status: "archived", limit: 1 })).toMatchObject({ items: [{ archived: true }], nextCursor: cursor }); expect(await client.list({ status: "archived", limit: 1 })).toMatchObject({ items: [{ archived: true }], nextCursor: cursor });
expect(await client.list({ status: "archived", limit: 1, cursor })).toEqual({ items: [], nextCursor: null }); expect(await client.list({ status: "archived", limit: 1, cursor })).toEqual({ items: [], nextCursor: null });
}); });
it.each([true, false])("returns one archived post without skipping buffered posts when has_more=%s", async (hasMore) => {
const cursor = "2026-01-01T00:00:00.123456Z";
const newest = { ...thread, thread_metadata: { ...thread.thread_metadata, archived: true, archive_timestamp: "2026-01-01T00:00:00.123456+00:00" } };
const older = { ...newest, id: "100000000000000008", thread_metadata: { ...newest.thread_metadata, archive_timestamp: "2026-01-01T00:00:00.123455+00:00" } };
const { client } = setup({
[`/channels/${forumId}`]: forum,
[`/channels/${forumId}/threads/archived/public?limit=2`]: { threads: [newest, older], has_more: hasMore },
[`/channels/${forumId}/threads/archived/public?limit=2&before=${encodeURIComponent(cursor)}`]: { threads: [older], has_more: false },
});
const first = await client.list({ status: "archived", limit: 1 });
expect(first.items.map(({ id }) => id)).toEqual([newest.id]);
expect(first.nextCursor).toBe(cursor);
const last = await client.list({ status: "archived", limit: 1, cursor: first.nextCursor! });
expect(last.items.map(({ id }) => id)).toEqual([older.id]);
expect(last.nextCursor).toBeNull();
});
it.each([1, 2, 25, 100])("preserves archive request bounds and terminal pages for limit=%s", async (limit) => {
const { client } = setup({
[`/channels/${forumId}`]: forum,
[`/channels/${forumId}/threads/archived/public?limit=${Math.max(2, limit)}`]: { threads: [thread], has_more: false },
});
const result = await client.list({ status: "archived", limit });
expect(result.items.map(({ id }) => id)).toEqual([threadId]);
expect(result.nextCursor).toBeNull();
});
it.each([true, false])("does not invent a cursor for an empty archive response with has_more=%s", async (hasMore) => {
const { client } = setup({
[`/channels/${forumId}`]: forum,
[`/channels/${forumId}/threads/archived/public?limit=2`]: { threads: [], has_more: hasMore },
});
expect(await client.list({ status: "archived", limit: 1 })).toEqual({ items: [], nextCursor: null });
});
it.each([
{ parent_id: "100000000000000099" },
{ guild_id: "100000000000000099" },
{ type: 12 },
])("filters unrelated archives before slicing and choosing a continuation: %j", async (outside) => {
const cursor = "2026-01-01T00:00:00Z";
const unrelated = { ...thread, ...outside, id: "100000000000000007", thread_metadata: { ...thread.thread_metadata, archive_timestamp: "2025-12-31T00:00:00Z" } };
const newerUnrelated = { ...unrelated, thread_metadata: { ...unrelated.thread_metadata, archive_timestamp: "2026-01-02T00:00:00Z" } };
for (const threads of [[newerUnrelated, thread], [thread, unrelated]]) {
for (const hasMore of [false, true]) {
const { client } = setup({
[`/channels/${forumId}`]: forum,
[`/channels/${forumId}/threads/archived/public?limit=2`]: { threads, has_more: hasMore },
[`/channels/${forumId}/threads/archived/public?limit=2&before=${encodeURIComponent(cursor)}`]: { threads: [], has_more: false },
});
const result = await client.list({ status: "archived", limit: 1 });
expect(result.items.map(({ id }) => id)).toEqual([threadId]);
expect(result.nextCursor).toBe(hasMore ? cursor : null);
if (result.nextCursor) expect(await client.list({ status: "archived", limit: 1, cursor: result.nextCursor })).toEqual({ items: [], nextCursor: null });
}
}
});
it.each([true, false])("preserves progress for a fully filtered archive page with has_more=%s", async (hasMore) => {
const cursor = "2026-01-01T00:00:00Z";
const { client } = setup({
[`/channels/${forumId}`]: forum,
[`/channels/${forumId}/threads/archived/public?limit=2`]: { threads: [{ ...thread, parent_id: "100000000000000099" }], has_more: hasMore },
[`/channels/${forumId}/threads/archived/public?limit=2&before=${encodeURIComponent(cursor)}`]: { threads: [{ ...thread, thread_metadata: { ...thread.thread_metadata, archive_timestamp: "2025-12-31T00:00:00Z" } }], has_more: false },
});
const result = await client.list({ status: "archived", limit: 1 });
expect(result).toEqual({ items: [], nextCursor: hasMore ? cursor : null });
if (result.nextCursor) {
const next = await client.list({ status: "archived", limit: 1, cursor: result.nextCursor });
expect(next.items.map(({ id }) => id)).toEqual([threadId]);
expect(next.nextCursor).toBeNull();
}
});
it("normalizes offset archive timestamps without losing cursor precision", async () => { it("normalizes offset archive timestamps without losing cursor precision", async () => {
const raw = "2026-01-01T00:00:00.123456+00:00"; const raw = "2026-01-01T00:00:00.123456+00:00";
const cursor = "2026-01-01T00:00:00.123456Z"; const cursor = "2026-01-01T00:00:00.123456Z";
const { client } = setup({ const { client } = setup({
[`/channels/${forumId}`]: forum, [`/channels/${forumId}`]: forum,
[`/channels/${forumId}/threads/archived/public?limit=1`]: { threads: [{ ...thread, thread_metadata: { ...thread.thread_metadata, archived: true, archive_timestamp: raw } }], has_more: true }, [`/channels/${forumId}/threads/archived/public?limit=2`]: { threads: [{ ...thread, thread_metadata: { ...thread.thread_metadata, archived: true, archive_timestamp: raw } }], has_more: true },
[`/channels/${forumId}/threads/archived/public?limit=1&before=${encodeURIComponent(cursor)}`]: { threads: [], has_more: false }, [`/channels/${forumId}/threads/archived/public?limit=2&before=${encodeURIComponent(cursor)}`]: { threads: [], has_more: false },
}); });
const first = await client.list({ status: "archived", limit: 1 }); const first = await client.list({ status: "archived", limit: 1 });
expect(first.nextCursor).toBe(cursor); expect(first.nextCursor).toBe(cursor);
+8 -3
View File
@@ -148,10 +148,15 @@ export function createSuggestionsClient(options: { token: string; guildId: strin
const forum = await getForum(); const forum = await getForum();
if (status === "archived") { if (status === "archived") {
const before = query.cursor ? `&before=${encodeURIComponent(query.cursor)}` : ""; const before = query.cursor ? `&before=${encodeURIComponent(query.cursor)}` : "";
const data = await get<{ threads: Thread[]; has_more: boolean }>(`/channels/${forumId}/threads/archived/public?limit=${limit}${before}`); const data = await get<{ threads: Thread[]; has_more: boolean }>(`/channels/${forumId}/threads/archived/public?limit=${Math.max(2, limit)}${before}`);
const threads = data.threads.filter(belongs);
const page = threads.slice(0, limit);
// Resume after the last returned post, not the extra post fetched for
// Discord's minimum limit. Fully filtered pages must still advance.
const last = page.at(-1) ?? data.threads.at(-1);
return { return {
items: data.threads.filter(belongs).map((thread) => summary(thread, forum)), items: page.map((thread) => summary(thread, forum)),
nextCursor: data.has_more && data.threads.length ? archiveCursor(data.threads.at(-1)!.thread_metadata.archive_timestamp) : null, nextCursor: (data.has_more || threads.length > limit) && last ? archiveCursor(last.thread_metadata.archive_timestamp) : null,
}; };
} }
const data = await get<{ threads: Thread[] }>(`/guilds/${guildId}/threads/active`); const data = await get<{ threads: Thread[] }>(`/guilds/${guildId}/threads/active`);
+1 -1
View File
@@ -29,7 +29,7 @@ The bot must have **View Channel** and **Read Message History** for the forum an
| `/api/suggestions/:id` | Suggestion metadata and `originalPost`; `null` when the starter message was deleted. | | `/api/suggestions/:id` | Suggestion metadata and `originalPost`; `null` when the starter message was deleted. |
| `/api/suggestions/:id/messages?limit=25` | Discussion messages, newest first, including the starter if reached. | | `/api/suggestions/:id/messages?limit=25` | Discussion messages, newest first, including the starter if reached. |
Lists return `{ items, nextCursor }`. Pass `nextCursor` back as the URL-encoded `cursor` parameter with the same status. Limits are integers from 1 to 100. Active cursors are thread IDs; archived cursors are UTC archive timestamps normalized to `Z` while preserving fractional precision; message cursors are message IDs. Treat cursors as opaque. Messages may yield a final empty page because Discord does not provide a `has_more` flag for messages. Active threads are fetched via the guild active-threads endpoint, filtered to the forum, sorted, and paginated locally. Archives are paginated by Discord. This is a live view, not a consistent snapshot: threads can move between active and archived lists. Lists return `{ items, nextCursor }`. Pass `nextCursor` back as the URL-encoded `cursor` parameter with the same status. Limits are integers from 1 to 100. Active cursors are thread IDs; archived cursors are UTC archive timestamps normalized to `Z` while preserving fractional precision; message cursors are message IDs. Treat cursors as opaque. Messages may yield a final empty page because Discord does not provide a `has_more` flag for messages. Active threads are fetched via the guild active-threads endpoint, filtered to the forum, sorted, and paginated locally. Archives are paginated by Discord. For `limit=1`, the portal requests Discord's minimum of two posts but returns at most one; the cursor follows the last returned post so the extra post remains available on the next page, even when Discord reports no further upstream pages. This is a live view, not a consistent snapshot: threads can move between active and archived lists.
Suggestion fields: `id`, `title`, `authorId`, `createdAt`, `archived`, `locked`, `tags`, `messageCount`, `discordUrl`. Discord's message count is approximate, not a vote count. Detail messages include `id`, `author` (`id`, `name`), `content`, `createdAt`, `editedAt`, `reactions` (`emoji`, `count`), and `discordUrl`. Reactions remain reaction counts, not interpreted votes. Attachments, embeds, and rendered Discord Markdown are not mirrored; use Discord links for the original presentation. Suggestion fields: `id`, `title`, `authorId`, `createdAt`, `archived`, `locked`, `tags`, `messageCount`, `discordUrl`. Discord's message count is approximate, not a vote count. Detail messages include `id`, `author` (`id`, `name`), `content`, `createdAt`, `editedAt`, `reactions` (`emoji`, `count`), and `discordUrl`. Reactions remain reaction counts, not interpreted votes. Attachments, embeds, and rendered Discord Markdown are not mirrored; use Discord links for the original presentation.