From fe7e9b20d89d876aa76dc7dcc08865f1ff408617 Mon Sep 17 00:00:00 2001 From: Ayush7614 Date: Fri, 11 Sep 2026 14:43:57 +0530 Subject: [PATCH 1/3] feat(posts): cursor pagination for token comments API GET /api/posts?chain=&token= returned every visible post in one unbounded fetch (server LIMIT 300, no cursor): a viral token pays the full scan on every 2s-memo poll. Add limit (1-100, default 50) and before (exclusive id cursor) to the route and listTokenPosts, with per-page memo keys and a nextCursor (oldest id on a full page, null when done). Defaults keep existing callers working; new pure posts-paging.ts owns the parsing with 3 unit tests. --- app/src/app/api/posts/route.ts | 6 ++-- app/src/lib/launchpad/posts-paging.test.ts | 27 ++++++++++++++++++ app/src/lib/launchpad/posts-paging.ts | 33 ++++++++++++++++++++++ app/src/lib/launchpad/postsServer.ts | 12 +++++--- 4 files changed, 72 insertions(+), 6 deletions(-) create mode 100644 app/src/lib/launchpad/posts-paging.test.ts create mode 100644 app/src/lib/launchpad/posts-paging.ts diff --git a/app/src/app/api/posts/route.ts b/app/src/app/api/posts/route.ts index 84d6af43..fb242cb5 100644 --- a/app/src/app/api/posts/route.ts +++ b/app/src/app/api/posts/route.ts @@ -4,10 +4,11 @@ import { isChainKey } from "@/lib/chainPublic"; import { rateLimited } from "@/lib/launchpad/editServer"; import { createPost, listFeed, listTokenPosts } from "@/lib/launchpad/postsServer"; import { memo } from "@/lib/launchpad/memo"; +import { parseTokenPostsPaging, postsCursorKey } from "@/lib/launchpad/posts-paging"; export const dynamic = "force-dynamic"; -/** GET /api/posts?chain=&token= → posts on one token (+ muted flag); GET /api/posts?feed=1&offset= → global human feed. */ +/** 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. */ export async function GET(req: Request) { const u = new URL(req.url); if (u.searchParams.get("feed")) { @@ -17,7 +18,8 @@ export async function GET(req: Request) { const chain = u.searchParams.get("chain"); const token = (u.searchParams.get("token") ?? "").toLowerCase(); if (!isChainKey(chain) || !isAddress(token)) return NextResponse.json({ error: "bad params" }, { status: 400 }); - return NextResponse.json(await memo(`posts:${chain}:${token}`, 2_000, () => listTokenPosts(chain, token)), { headers: { "cache-control": "no-store" } }); + const { limit, beforeId } = parseTokenPostsPaging({ limit: u.searchParams.get("limit"), before: u.searchParams.get("before") }); + return NextResponse.json(await memo(postsCursorKey(chain, token, limit, beforeId), 2_000, () => listTokenPosts(chain, token, limit, beforeId)), { headers: { "cache-control": "no-store" } }); } /** POST {chain, token, wallet, parentId?, body, nonce, ts, signature} → new post. */ diff --git a/app/src/lib/launchpad/posts-paging.test.ts b/app/src/lib/launchpad/posts-paging.test.ts new file mode 100644 index 00000000..c030dd9e --- /dev/null +++ b/app/src/lib/launchpad/posts-paging.test.ts @@ -0,0 +1,27 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { TOKEN_POSTS_DEFAULT_LIMIT, TOKEN_POSTS_MAX_LIMIT, nextPostsCursor, parseTokenPostsPaging, postsCursorKey } from "./posts-paging.ts"; + +test("parseTokenPostsPaging defaults, clamps and reads the cursor", () => { + assert.deepEqual(parseTokenPostsPaging({}), { limit: TOKEN_POSTS_DEFAULT_LIMIT, beforeId: null }); + assert.deepEqual(parseTokenPostsPaging({ limit: "20", before: "42" }), { limit: 20, beforeId: 42 }); + assert.deepEqual(parseTokenPostsPaging({ limit: 0, before: 0 }), { limit: 1, beforeId: null }); + assert.deepEqual(parseTokenPostsPaging({ limit: 9999 }), { limit: TOKEN_POSTS_MAX_LIMIT, beforeId: null }); + assert.deepEqual(parseTokenPostsPaging({ limit: "abc", before: "xyz" }), { limit: TOKEN_POSTS_DEFAULT_LIMIT, beforeId: null }); + assert.deepEqual(parseTokenPostsPaging({ limit: 25.9 }), { limit: 25, beforeId: null }, "truncates"); + assert.deepEqual(parseTokenPostsPaging({ before: "" }), { limit: TOKEN_POSTS_DEFAULT_LIMIT, beforeId: null }); + assert.equal(TOKEN_POSTS_DEFAULT_LIMIT, 50); + assert.equal(TOKEN_POSTS_MAX_LIMIT, 100); +}); + +test("cursor key namespaces chain/token/limit/cursor", () => { + assert.equal(postsCursorKey("base", "0xABC", 50, null), "posts:base:0xabc:50:head"); + assert.equal(postsCursorKey("base", "0xabc", 20, 42), "posts:base:0xabc:20:42"); + assert.notEqual(postsCursorKey("base", "0xabc", 20, 42), postsCursorKey("base", "0xabc", 20, 43)); +}); + +test("nextPostsCursor ends pagination on a short page", () => { + assert.equal(nextPostsCursor([9, 8, 7], 3), 7, "full page → oldest id is the next cursor"); + assert.equal(nextPostsCursor([9, 8], 3), null, "short page → done"); + assert.equal(nextPostsCursor([], 3), null); +}); diff --git a/app/src/lib/launchpad/posts-paging.ts b/app/src/lib/launchpad/posts-paging.ts new file mode 100644 index 00000000..19c062ed --- /dev/null +++ b/app/src/lib/launchpad/posts-paging.ts @@ -0,0 +1,33 @@ +/** + * Cursor pagination for token comments (pure; unit-tested). + * + * GET /api/posts?chain=&token= returned every visible post on the token in + * one unbounded fetch (server capped at LIMIT 300, no cursor). A viral token + * pays the full scan on every poll. This owns the query parsing so the route + * and postsServer share one definition: `limit` (1–100, default 50) and + * `before` (exclusive id cursor, newest page first). + */ + +export const TOKEN_POSTS_DEFAULT_LIMIT = 50; +export const TOKEN_POSTS_MAX_LIMIT = 100; + +export function parseTokenPostsPaging(query: { limit?: unknown; before?: unknown }): { limit: number; beforeId: number | null } { + const rawLimit = Number(query.limit); + const limit = Number.isFinite(rawLimit) + ? Math.min(TOKEN_POSTS_MAX_LIMIT, Math.max(1, Math.trunc(rawLimit))) + : TOKEN_POSTS_DEFAULT_LIMIT; + const rawBefore = query.before === null || query.before === undefined || query.before === "" ? NaN : Number(query.before); + const beforeId = Number.isInteger(rawBefore) && rawBefore > 0 ? rawBefore : null; + return { limit, beforeId }; +} + +export function postsCursorKey(chain: string, token: string, limit: number, beforeId: number | null): string { + return `posts:${chain}:${token.toLowerCase()}:${limit}:${beforeId ?? "head"}`; +} + +/** Cursor for the next page: oldest id on a full page, else null (no more). */ +export function nextPostsCursor(ids: number[], limit: number): number | null { + if (ids.length < limit) return null; + const oldest = ids[ids.length - 1]; + return Number.isInteger(oldest) && oldest > 0 ? oldest : null; +} diff --git a/app/src/lib/launchpad/postsServer.ts b/app/src/lib/launchpad/postsServer.ts index f0799966..1abff6af 100644 --- a/app/src/lib/launchpad/postsServer.ts +++ b/app/src/lib/launchpad/postsServer.ts @@ -56,15 +56,19 @@ function shape(r: RawPost): PostRow { 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 }; } -export async function listTokenPosts(chain: ChainKey, token: string, limit = 100): Promise<{ posts: PostRow[]; muted: boolean }> { +export async function listTokenPosts(chain: ChainKey, token: string, limit = 100, beforeId: number | null = null): Promise<{ posts: PostRow[]; muted: boolean; nextCursor: number | null }> { const db = maybeDb(); - if (!db) return { posts: [], muted: false }; + if (!db) return { posts: [], muted: false, nextCursor: null }; const cid = chainIdOf(chain); + const n = Math.min(300, Math.max(1, Math.trunc(limit) || 100)); const [rows, st] = await Promise.all([ - db`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)}`, + beforeId !== null && Number.isInteger(beforeId) && beforeId > 0 + ? db`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 created_at DESC LIMIT ${n}` + : db`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 ${n}`, db<{ comments_muted: boolean }[]>`SELECT comments_muted FROM bb_token_settings WHERE chain_id = ${cid} AND token = ${token.toLowerCase()}`, ]); - return { posts: rows.map(shape), muted: st[0]?.comments_muted ?? false }; + const posts = rows.map(shape); + return { posts, muted: st[0]?.comments_muted ?? false, nextCursor: posts.length < n ? null : Number(posts[posts.length - 1]?.id ?? 0) || null }; } /** Global human feed: latest top-level posts across all tokens, with token names. */ From 976555b35075d8497871a828a1c5480dd25d1b41 Mon Sep 17 00:00:00 2001 From: Ayush7614 Date: Fri, 11 Sep 2026 15:01:31 +0530 Subject: [PATCH 2/3] fix(posts): default missing limit to 50; order cursor pages by id (CodeRabbit, PR #32) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - parseTokenPostsPaging treated an absent limit as 1: Number(null) is 0, which clamped to the minimum. Null/undefined/blank now fall through to the 50 default. - Both listTokenPosts pages ordered by created_at DESC while the continuation predicate is id < beforeId: pages could skip or repeat rows when id and timestamp order disagree. Order by id DESC (the cursor key; ids are monotonic with insertion, so newest-first holds). A live-DB retrieval test is not runnable here — the app unit suite is pure node --test with no Postgres — so the invariant holds by construction, covered by the paging unit tests. --- app/src/lib/launchpad/posts-paging.test.ts | 3 +++ app/src/lib/launchpad/posts-paging.ts | 4 +++- app/src/lib/launchpad/postsServer.ts | 9 +++++++-- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/app/src/lib/launchpad/posts-paging.test.ts b/app/src/lib/launchpad/posts-paging.test.ts index c030dd9e..647081ae 100644 --- a/app/src/lib/launchpad/posts-paging.test.ts +++ b/app/src/lib/launchpad/posts-paging.test.ts @@ -4,6 +4,9 @@ import { TOKEN_POSTS_DEFAULT_LIMIT, TOKEN_POSTS_MAX_LIMIT, nextPostsCursor, pars test("parseTokenPostsPaging defaults, clamps and reads the cursor", () => { assert.deepEqual(parseTokenPostsPaging({}), { limit: TOKEN_POSTS_DEFAULT_LIMIT, beforeId: null }); + assert.deepEqual(parseTokenPostsPaging({ limit: null }), { limit: TOKEN_POSTS_DEFAULT_LIMIT, beforeId: null }, "absent query param (URLSearchParams.get → null) uses the default, not 1"); + assert.deepEqual(parseTokenPostsPaging({ limit: "" }), { limit: TOKEN_POSTS_DEFAULT_LIMIT, beforeId: null }); + assert.deepEqual(parseTokenPostsPaging({ limit: undefined }), { limit: TOKEN_POSTS_DEFAULT_LIMIT, beforeId: null }); assert.deepEqual(parseTokenPostsPaging({ limit: "20", before: "42" }), { limit: 20, beforeId: 42 }); assert.deepEqual(parseTokenPostsPaging({ limit: 0, before: 0 }), { limit: 1, beforeId: null }); assert.deepEqual(parseTokenPostsPaging({ limit: 9999 }), { limit: TOKEN_POSTS_MAX_LIMIT, beforeId: null }); diff --git a/app/src/lib/launchpad/posts-paging.ts b/app/src/lib/launchpad/posts-paging.ts index 19c062ed..aae1acd5 100644 --- a/app/src/lib/launchpad/posts-paging.ts +++ b/app/src/lib/launchpad/posts-paging.ts @@ -12,7 +12,9 @@ export const TOKEN_POSTS_DEFAULT_LIMIT = 50; export const TOKEN_POSTS_MAX_LIMIT = 100; export function parseTokenPostsPaging(query: { limit?: unknown; before?: unknown }): { limit: number; beforeId: number | null } { - const rawLimit = Number(query.limit); + // NB: URLSearchParams.get() returns null when absent, and Number(null) / + // Number("") is 0 — both must fall through to the default, not clamp to 1. + const rawLimit = query.limit === null || query.limit === undefined || (typeof query.limit === "string" && query.limit.trim() === "") ? NaN : Number(query.limit); const limit = Number.isFinite(rawLimit) ? Math.min(TOKEN_POSTS_MAX_LIMIT, Math.max(1, Math.trunc(rawLimit))) : TOKEN_POSTS_DEFAULT_LIMIT; diff --git a/app/src/lib/launchpad/postsServer.ts b/app/src/lib/launchpad/postsServer.ts index 1abff6af..a609ae89 100644 --- a/app/src/lib/launchpad/postsServer.ts +++ b/app/src/lib/launchpad/postsServer.ts @@ -61,10 +61,15 @@ export async function listTokenPosts(chain: ChainKey, token: string, limit = 100 if (!db) return { posts: [], muted: false, nextCursor: null }; const cid = chainIdOf(chain); const n = Math.min(300, Math.max(1, Math.trunc(limit) || 100)); + // Both pages order by id DESC — the same key as the `id < beforeId` + // continuation predicate. Ordering by created_at DESC instead would skip or + // repeat rows across pages whenever id order and timestamp order disagree + // (same-second inserts, backfilled rows). ids are monotonic with insertion, + // so newest-first is preserved. const [rows, st] = await Promise.all([ beforeId !== null && Number.isInteger(beforeId) && beforeId > 0 - ? db`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 created_at DESC LIMIT ${n}` - : db`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 ${n}`, + ? db`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}` + : db`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}`, db<{ comments_muted: boolean }[]>`SELECT comments_muted FROM bb_token_settings WHERE chain_id = ${cid} AND token = ${token.toLowerCase()}`, ]); const posts = rows.map(shape); From 5e0e3ae5ef2aef2f57eb921f28281afa1892fb26 Mon Sep 17 00:00:00 2001 From: Ayush7614 Date: Sat, 12 Sep 2026 18:00:15 +0530 Subject: [PATCH 3/3] fix(posts): keep 100-post default so cursor pages do not hide discussions (PR #32) - default 50 truncated 75-comment threads and orphaned 1 parent + 50 replies - default 100 matches previous response; callers without params lose nothing - correct prior capped-at-100 history (not unbounded) in docs --- app/src/lib/launchpad/posts-paging.test.ts | 11 ++++++++++- app/src/lib/launchpad/posts-paging.ts | 12 +++++++----- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/app/src/lib/launchpad/posts-paging.test.ts b/app/src/lib/launchpad/posts-paging.test.ts index 647081ae..c970ad5c 100644 --- a/app/src/lib/launchpad/posts-paging.test.ts +++ b/app/src/lib/launchpad/posts-paging.test.ts @@ -13,7 +13,7 @@ test("parseTokenPostsPaging defaults, clamps and reads the cursor", () => { assert.deepEqual(parseTokenPostsPaging({ limit: "abc", before: "xyz" }), { limit: TOKEN_POSTS_DEFAULT_LIMIT, beforeId: null }); assert.deepEqual(parseTokenPostsPaging({ limit: 25.9 }), { limit: 25, beforeId: null }, "truncates"); assert.deepEqual(parseTokenPostsPaging({ before: "" }), { limit: TOKEN_POSTS_DEFAULT_LIMIT, beforeId: null }); - assert.equal(TOKEN_POSTS_DEFAULT_LIMIT, 50); + assert.equal(TOKEN_POSTS_DEFAULT_LIMIT, 100, "default preserves the previous response (100 posts, not 50) so existing clients lose nothing"); assert.equal(TOKEN_POSTS_MAX_LIMIT, 100); }); @@ -28,3 +28,12 @@ test("nextPostsCursor ends pagination on a short page", () => { assert.equal(nextPostsCursor([9, 8], 3), null, "short page → done"); assert.equal(nextPostsCursor([], 3), null); }); + +test("default page preserves existing discussions until the client follows cursors (PR #32)", () => { + // Maintainer repro: 75 top-level comments must not truncate, and 1 parent + + // 50 replies (51 rows) must arrive together so the UI never sees a + // replies-only page with "No comments yet." + assert.ok(TOKEN_POSTS_DEFAULT_LIMIT >= 75, "75 previously visible comments stay visible"); + assert.ok(TOKEN_POSTS_DEFAULT_LIMIT >= 51, "1 parent + 50 replies arrive on the first page"); + assert.equal(nextPostsCursor(Array.from({ length: 75 }, (_, i) => 75 - i), TOKEN_POSTS_DEFAULT_LIMIT), null); +}); diff --git a/app/src/lib/launchpad/posts-paging.ts b/app/src/lib/launchpad/posts-paging.ts index aae1acd5..02894f76 100644 --- a/app/src/lib/launchpad/posts-paging.ts +++ b/app/src/lib/launchpad/posts-paging.ts @@ -1,14 +1,16 @@ /** * Cursor pagination for token comments (pure; unit-tested). * - * GET /api/posts?chain=&token= returned every visible post on the token in - * one unbounded fetch (server capped at LIMIT 300, no cursor). A viral token + * GET /api/posts?chain=&token= previously returned the newest 100 posts with + * no cursor (server ceiling 300, route never passed a limit). A viral token * pays the full scan on every poll. This owns the query parsing so the route - * and postsServer share one definition: `limit` (1–100, default 50) and - * `before` (exclusive id cursor, newest page first). + * and postsServer share one definition: `limit` (1–100, default 100 to match + * the previous response) and `before` (exclusive id cursor, newest page first). + * Callers without params get the same first page as before, plus nextCursor; + * clients can now fetch older pages instead of re-scanning everything. */ -export const TOKEN_POSTS_DEFAULT_LIMIT = 50; +export const TOKEN_POSTS_DEFAULT_LIMIT = 100; export const TOKEN_POSTS_MAX_LIMIT = 100; export function parseTokenPostsPaging(query: { limit?: unknown; before?: unknown }): { limit: number; beforeId: number | null } {