Skip to content

Commit ea635bc

Browse files
authored
Merge pull request #32 from Ayush7614/feat/posts-cursor-pagination
feat(posts): cursor pagination for token comments API
2 parents ebe48bf + 5e0e3ae commit ea635bc

4 files changed

Lines changed: 93 additions & 6 deletions

File tree

‎app/src/app/api/posts/route.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@ import { isChainKey } from "@/lib/chainPublic";
44
import { rateLimited } from "@/lib/launchpad/editServer";
55
import { createPost, listFeed, listTokenPosts } from "@/lib/launchpad/postsServer";
66
import { memo } from "@/lib/launchpad/memo";
7+
import { parseTokenPostsPaging, postsCursorKey } from "@/lib/launchpad/posts-paging";
78

89
export const dynamic = "force-dynamic";
910

10-
/** GET /api/posts?chain=&token= → posts on one token (+ muted flag); GET /api/posts?feed=1&offset= → global human feed. */
11+
/** GET /api/posts?chain=&token=[&limit=&before=] → one page of posts on a token (+ muted flag, nextCursor); GET /api/posts?feed=1&offset= → global human feed. */
1112
export async function GET(req: Request) {
1213
const u = new URL(req.url);
1314
if (u.searchParams.get("feed")) {
@@ -17,7 +18,8 @@ export async function GET(req: Request) {
1718
const chain = u.searchParams.get("chain");
1819
const token = (u.searchParams.get("token") ?? "").toLowerCase();
1920
if (!isChainKey(chain) || !isAddress(token)) return NextResponse.json({ error: "bad params" }, { status: 400 });
20-
return NextResponse.json(await memo(`posts:${chain}:${token}`, 2_000, () => listTokenPosts(chain, token)), { headers: { "cache-control": "no-store" } });
21+
const { limit, beforeId } = parseTokenPostsPaging({ limit: u.searchParams.get("limit"), before: u.searchParams.get("before") });
22+
return NextResponse.json(await memo(postsCursorKey(chain, token, limit, beforeId), 2_000, () => listTokenPosts(chain, token, limit, beforeId)), { headers: { "cache-control": "no-store" } });
2123
}
2224

2325
/** POST {chain, token, wallet, parentId?, body, nonce, ts, signature} → new post. */
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { test } from "node:test";
2+
import assert from "node:assert/strict";
3+
import { TOKEN_POSTS_DEFAULT_LIMIT, TOKEN_POSTS_MAX_LIMIT, nextPostsCursor, parseTokenPostsPaging, postsCursorKey } from "./posts-paging.ts";
4+
5+
test("parseTokenPostsPaging defaults, clamps and reads the cursor", () => {
6+
assert.deepEqual(parseTokenPostsPaging({}), { limit: TOKEN_POSTS_DEFAULT_LIMIT, beforeId: null });
7+
assert.deepEqual(parseTokenPostsPaging({ limit: null }), { limit: TOKEN_POSTS_DEFAULT_LIMIT, beforeId: null }, "absent query param (URLSearchParams.get → null) uses the default, not 1");
8+
assert.deepEqual(parseTokenPostsPaging({ limit: "" }), { limit: TOKEN_POSTS_DEFAULT_LIMIT, beforeId: null });
9+
assert.deepEqual(parseTokenPostsPaging({ limit: undefined }), { limit: TOKEN_POSTS_DEFAULT_LIMIT, beforeId: null });
10+
assert.deepEqual(parseTokenPostsPaging({ limit: "20", before: "42" }), { limit: 20, beforeId: 42 });
11+
assert.deepEqual(parseTokenPostsPaging({ limit: 0, before: 0 }), { limit: 1, beforeId: null });
12+
assert.deepEqual(parseTokenPostsPaging({ limit: 9999 }), { limit: TOKEN_POSTS_MAX_LIMIT, beforeId: null });
13+
assert.deepEqual(parseTokenPostsPaging({ limit: "abc", before: "xyz" }), { limit: TOKEN_POSTS_DEFAULT_LIMIT, beforeId: null });
14+
assert.deepEqual(parseTokenPostsPaging({ limit: 25.9 }), { limit: 25, beforeId: null }, "truncates");
15+
assert.deepEqual(parseTokenPostsPaging({ before: "" }), { limit: TOKEN_POSTS_DEFAULT_LIMIT, beforeId: null });
16+
assert.equal(TOKEN_POSTS_DEFAULT_LIMIT, 100, "default preserves the previous response (100 posts, not 50) so existing clients lose nothing");
17+
assert.equal(TOKEN_POSTS_MAX_LIMIT, 100);
18+
});
19+
20+
test("cursor key namespaces chain/token/limit/cursor", () => {
21+
assert.equal(postsCursorKey("base", "0xABC", 50, null), "posts:base:0xabc:50:head");
22+
assert.equal(postsCursorKey("base", "0xabc", 20, 42), "posts:base:0xabc:20:42");
23+
assert.notEqual(postsCursorKey("base", "0xabc", 20, 42), postsCursorKey("base", "0xabc", 20, 43));
24+
});
25+
26+
test("nextPostsCursor ends pagination on a short page", () => {
27+
assert.equal(nextPostsCursor([9, 8, 7], 3), 7, "full page → oldest id is the next cursor");
28+
assert.equal(nextPostsCursor([9, 8], 3), null, "short page → done");
29+
assert.equal(nextPostsCursor([], 3), null);
30+
});
31+
32+
test("default page preserves existing discussions until the client follows cursors (PR #32)", () => {
33+
// Maintainer repro: 75 top-level comments must not truncate, and 1 parent +
34+
// 50 replies (51 rows) must arrive together so the UI never sees a
35+
// replies-only page with "No comments yet."
36+
assert.ok(TOKEN_POSTS_DEFAULT_LIMIT >= 75, "75 previously visible comments stay visible");
37+
assert.ok(TOKEN_POSTS_DEFAULT_LIMIT >= 51, "1 parent + 50 replies arrive on the first page");
38+
assert.equal(nextPostsCursor(Array.from({ length: 75 }, (_, i) => 75 - i), TOKEN_POSTS_DEFAULT_LIMIT), null);
39+
});
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/**
2+
* Cursor pagination for token comments (pure; unit-tested).
3+
*
4+
* GET /api/posts?chain=&token= previously returned the newest 100 posts with
5+
* no cursor (server ceiling 300, route never passed a limit). A viral token
6+
* pays the full scan on every poll. This owns the query parsing so the route
7+
* and postsServer share one definition: `limit` (1–100, default 100 to match
8+
* the previous response) and `before` (exclusive id cursor, newest page first).
9+
* Callers without params get the same first page as before, plus nextCursor;
10+
* clients can now fetch older pages instead of re-scanning everything.
11+
*/
12+
13+
export const TOKEN_POSTS_DEFAULT_LIMIT = 100;
14+
export const TOKEN_POSTS_MAX_LIMIT = 100;
15+
16+
export function parseTokenPostsPaging(query: { limit?: unknown; before?: unknown }): { limit: number; beforeId: number | null } {
17+
// NB: URLSearchParams.get() returns null when absent, and Number(null) /
18+
// Number("") is 0 — both must fall through to the default, not clamp to 1.
19+
const rawLimit = query.limit === null || query.limit === undefined || (typeof query.limit === "string" && query.limit.trim() === "") ? NaN : Number(query.limit);
20+
const limit = Number.isFinite(rawLimit)
21+
? Math.min(TOKEN_POSTS_MAX_LIMIT, Math.max(1, Math.trunc(rawLimit)))
22+
: TOKEN_POSTS_DEFAULT_LIMIT;
23+
const rawBefore = query.before === null || query.before === undefined || query.before === "" ? NaN : Number(query.before);
24+
const beforeId = Number.isInteger(rawBefore) && rawBefore > 0 ? rawBefore : null;
25+
return { limit, beforeId };
26+
}
27+
28+
export function postsCursorKey(chain: string, token: string, limit: number, beforeId: number | null): string {
29+
return `posts:${chain}:${token.toLowerCase()}:${limit}:${beforeId ?? "head"}`;
30+
}
31+
32+
/** Cursor for the next page: oldest id on a full page, else null (no more). */
33+
export function nextPostsCursor(ids: number[], limit: number): number | null {
34+
if (ids.length < limit) return null;
35+
const oldest = ids[ids.length - 1];
36+
return Number.isInteger(oldest) && oldest > 0 ? oldest : null;
37+
}

‎app/src/lib/launchpad/postsServer.ts‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,15 +56,24 @@ function shape(r: RawPost): PostRow {
5656
return { id: Number(r.id), chain: chainKeyOf(r.chain_id) ?? "base", token: r.token, wallet: r.wallet, parent_id: r.parent_id === null ? null : Number(r.parent_id), body: r.body, tag: (r.tag as Tag) ?? null, created_at: r.created_at, reports: r.reports, hidden: r.hidden, symbol: r.symbol, name: r.name };
5757
}
5858

59-
export async function listTokenPosts(chain: ChainKey, token: string, limit = 100): Promise<{ posts: PostRow[]; muted: boolean }> {
59+
export async function listTokenPosts(chain: ChainKey, token: string, limit = 100, beforeId: number | null = null): Promise<{ posts: PostRow[]; muted: boolean; nextCursor: number | null }> {
6060
const db = maybeDb();
61-
if (!db) return { posts: [], muted: false };
61+
if (!db) return { posts: [], muted: false, nextCursor: null };
6262
const cid = chainIdOf(chain);
63+
const n = Math.min(300, Math.max(1, Math.trunc(limit) || 100));
64+
// Both pages order by id DESC — the same key as the `id < beforeId`
65+
// continuation predicate. Ordering by created_at DESC instead would skip or
66+
// repeat rows across pages whenever id order and timestamp order disagree
67+
// (same-second inserts, backfilled rows). ids are monotonic with insertion,
68+
// so newest-first is preserved.
6369
const [rows, st] = await Promise.all([
64-
db<RawPost[]>`SELECT id, chain_id, token, wallet, parent_id, body, tag, created_at, reports, hidden FROM bb_posts WHERE chain_id = ${cid} AND token = ${token.toLowerCase()} AND NOT hidden ORDER BY created_at DESC LIMIT ${Math.min(300, limit)}`,
70+
beforeId !== null && Number.isInteger(beforeId) && beforeId > 0
71+
? db<RawPost[]>`SELECT id, chain_id, token, wallet, parent_id, body, tag, created_at, reports, hidden FROM bb_posts WHERE chain_id = ${cid} AND token = ${token.toLowerCase()} AND NOT hidden AND id < ${beforeId} ORDER BY id DESC LIMIT ${n}`
72+
: db<RawPost[]>`SELECT id, chain_id, token, wallet, parent_id, body, tag, created_at, reports, hidden FROM bb_posts WHERE chain_id = ${cid} AND token = ${token.toLowerCase()} AND NOT hidden ORDER BY id DESC LIMIT ${n}`,
6573
db<{ comments_muted: boolean }[]>`SELECT comments_muted FROM bb_token_settings WHERE chain_id = ${cid} AND token = ${token.toLowerCase()}`,
6674
]);
67-
return { posts: rows.map(shape), muted: st[0]?.comments_muted ?? false };
75+
const posts = rows.map(shape);
76+
return { posts, muted: st[0]?.comments_muted ?? false, nextCursor: posts.length < n ? null : Number(posts[posts.length - 1]?.id ?? 0) || null };
6877
}
6978

7079
/** Global human feed: latest top-level posts across all tokens, with token names. */

0 commit comments

Comments
 (0)