Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions app/src/app/api/posts/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")) {
Expand All @@ -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" } });

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Keep existing discussions reachable when introducing cursor pages. This call changes the default response from 100 posts to 50, but TokenComments only reads posts/muted, replaces its array on refresh, and never follows nextCursor; #31 still does the same. With 75 top-level comments, 25 previously visible comments disappear. With one parent followed by 50 replies, the page contains only replies and the UI says "No comments yet." Preserve the old default until the client supports paging, or wire cursor fetching and merging into the client here, with tests for threads crossing page boundaries and live refresh.

}

/** POST {chain, token, wallet, parentId?, body, nonce, ts, signature} → new post. */
Expand Down
39 changes: 39 additions & 0 deletions app/src/lib/launchpad/posts-paging.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
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: 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 });
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, 100, "default preserves the previous response (100 posts, not 50) so existing clients lose nothing");
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);
});

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);
});
37 changes: 37 additions & 0 deletions app/src/lib/launchpad/posts-paging.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* Cursor pagination for token comments (pure; unit-tested).
*
* 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 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 = 100;
export const TOKEN_POSTS_MAX_LIMIT = 100;

export function parseTokenPostsPaging(query: { limit?: unknown; before?: unknown }): { limit: number; beforeId: number | null } {
// 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;
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;
}
17 changes: 13 additions & 4 deletions app/src/lib/launchpad/postsServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,15 +56,24 @@ 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));
// 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([
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)}`,
beforeId !== null && Number.isInteger(beforeId) && beforeId > 0
? 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}`
: 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}`,
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. */
Expand Down
Loading