diff --git a/app/src/app/api/launch/candles/route.ts b/app/src/app/api/launch/candles/route.ts index 905fb1f..5bbf158 100644 --- a/app/src/app/api/launch/candles/route.ts +++ b/app/src/app/api/launch/candles/route.ts @@ -20,34 +20,39 @@ export async function GET(req: Request) { const token = (u.searchParams.get("token") ?? "").toLowerCase(); const interval = u.searchParams.get("interval") ?? "5m"; if (!isChainKey(chain) || !isAddress(token) || !isInterval(interval)) return NextResponse.json({ error: "bad params" }, { status: 400 }); - const usd = await ethUsd(); - const l = await memo(`launch:${chain}:${token}`, 3_000, () => getLaunch(chain, token, usd)); - if (!l) return NextResponse.json({ error: "not found" }, { status: 404 }); - // getLaunch resolves issuer-registry stock quotes too; the static quote list - // would relabel them "?" and could aggregate with the wrong decimals. - const q = { symbol: l.quote_symbol, decimals: l.quote_decimals }; - const intervalS = INTERVALS[interval]; - const launchT = Math.floor(new Date(l.block_time).getTime() / 1000); - const requestedAt = Math.floor(Date.now() / 1000); - const fromRaw = Number(u.searchParams.get("from")); - const from = boundedCandleFrom(fromRaw, launchT, requestedAt, intervalS); - const wallet = u.searchParams.get("wallet"); - const snapshot = await memo(`candles:${chain}:${token}:${interval}:${from}`, 3_000, async () => { - const asOf = requestedAt; - const [candles, priorPrice] = await Promise.all([ - getCandles(chain, token, intervalS, from, q.decimals, asOf), - getCandleBaseline(chain, token, from, q.decimals), - ]); - return { candles, priorPrice, asOf }; - }); - // A cache hit can predate this request. Markers must share its cutoff so a - // new wallet trade cannot appear ahead of the corresponding OHLCV update. - const mine = wallet && isAddress(wallet) ? await getWalletSwaps(chain, token, wallet, snapshot.asOf) : null; - // launch price in quote per token, from the start tick - const launchPrice = 1 / (Math.pow(1.0001, l.start_tick) * Math.pow(10, q.decimals - 18)); - const baseline = { price: snapshot.priorPrice ?? launchPrice, hasPriorTrades: snapshot.priorPrice !== null }; - return NextResponse.json( - { interval, from, asOf: snapshot.asOf, baseline, launch: { t: launchT, price: launchPrice }, quote: { symbol: q.symbol, decimals: q.decimals, usd: l.quote_usd }, supply: Number(BigInt(l.supply)) / 1e18, candles: snapshot.candles, mine }, - { headers: { "cache-control": "no-store" } }, - ); + try { + const usd = await ethUsd(); + const l = await memo(`launch:${chain}:${token}`, 3_000, () => getLaunch(chain, token, usd)); + if (!l) return NextResponse.json({ error: "not found" }, { status: 404 }); + // getLaunch resolves issuer-registry stock quotes too; the static quote list + // would relabel them "?" and could aggregate with the wrong decimals. + const q = { symbol: l.quote_symbol, decimals: l.quote_decimals }; + const intervalS = INTERVALS[interval]; + const launchT = Math.floor(new Date(l.block_time).getTime() / 1000); + const requestedAt = Math.floor(Date.now() / 1000); + const fromRaw = Number(u.searchParams.get("from")); + const from = boundedCandleFrom(fromRaw, launchT, requestedAt, intervalS); + const wallet = u.searchParams.get("wallet"); + const snapshot = await memo(`candles:${chain}:${token}:${interval}:${from}`, 3_000, async () => { + const asOf = requestedAt; + const [candles, priorPrice] = await Promise.all([ + getCandles(chain, token, intervalS, from, q.decimals, asOf), + getCandleBaseline(chain, token, from, q.decimals), + ]); + return { candles, priorPrice, asOf }; + }); + // A cache hit can predate this request. Markers must share its cutoff so a + // new wallet trade cannot appear ahead of the corresponding OHLCV update. + const mine = wallet && isAddress(wallet) ? await getWalletSwaps(chain, token, wallet, snapshot.asOf) : null; + // launch price in quote per token, from the start tick + const launchPrice = 1 / (Math.pow(1.0001, l.start_tick) * Math.pow(10, q.decimals - 18)); + const baseline = { price: snapshot.priorPrice ?? launchPrice, hasPriorTrades: snapshot.priorPrice !== null }; + return NextResponse.json( + { interval, from, asOf: snapshot.asOf, baseline, launch: { t: launchT, price: launchPrice }, quote: { symbol: q.symbol, decimals: q.decimals, usd: l.quote_usd }, supply: Number(BigInt(l.supply)) / 1e18, candles: snapshot.candles, mine }, + { headers: { "cache-control": "no-store" } }, + ); + } catch (err) { + console.error("[launch] candles failed:", err instanceof Error ? err.message : err); + return NextResponse.json({ error: "could not load candles" }, { status: 502 }); + } } diff --git a/app/src/app/api/launch/feed/route.ts b/app/src/app/api/launch/feed/route.ts index 67ce921..5b830e6 100644 --- a/app/src/app/api/launch/feed/route.ts +++ b/app/src/app/api/launch/feed/route.ts @@ -6,6 +6,11 @@ import { memo } from "@/lib/launchpad/memo"; export const dynamic = "force-dynamic"; export async function GET() { - const usd = await ethUsd(); - return NextResponse.json({ items: await memo("feed", 2_000, () => getLaunchFeed(24, usd)) }, { headers: { "cache-control": "no-store" } }); + try { + const usd = await ethUsd(); + return NextResponse.json({ items: await memo("feed", 2_000, () => getLaunchFeed(24, usd)) }, { headers: { "cache-control": "no-store" } }); + } catch (err) { + console.error("[launch] feed failed:", err instanceof Error ? err.message : err); + return NextResponse.json({ error: "could not load feed" }, { status: 502 }); + } } diff --git a/app/src/app/api/launch/holders/route.ts b/app/src/app/api/launch/holders/route.ts index a8f64d1..0ef57ff 100644 --- a/app/src/app/api/launch/holders/route.ts +++ b/app/src/app/api/launch/holders/route.ts @@ -12,7 +12,12 @@ export async function GET(req: Request) { const chain = u.searchParams.get("chain"); const token = u.searchParams.get("token"); if (!isChainKey(chain) || !token || !isAddress(token)) return NextResponse.json({ error: "bad params" }, { status: 400 }); - const panel = await memo(`holders:${chain}:${token.toLowerCase()}`, 5_000, () => getHolderPanel(chain, token)); - if (!panel) return NextResponse.json({ error: "not found" }, { status: 404 }); - return NextResponse.json(panel, { headers: { "cache-control": "no-store" } }); + try { + const panel = await memo(`holders:${chain}:${token.toLowerCase()}`, 5_000, () => getHolderPanel(chain, token)); + if (!panel) return NextResponse.json({ error: "not found" }, { status: 404 }); + return NextResponse.json(panel, { headers: { "cache-control": "no-store" } }); + } catch (err) { + console.error("[launch] holders failed:", err instanceof Error ? err.message : err); + return NextResponse.json({ error: "could not load holders" }, { status: 502 }); + } } diff --git a/app/src/app/api/launch/list/route.ts b/app/src/app/api/launch/list/route.ts index fdfb772..12b9608 100644 --- a/app/src/app/api/launch/list/route.ts +++ b/app/src/app/api/launch/list/route.ts @@ -18,7 +18,12 @@ export async function GET(req: Request) { const filter = isFilter(f) ? f : null; const limit = clampLimit(u.searchParams.get("limit"), 50); const offset = clampOffset(u.searchParams.get("offset")); - const usd = await ethUsd(); - const page = await listLaunchesPage({ sort, window, chain, filter, limit, offset, ethUsd: usd }); - return NextResponse.json({ sort, window, chain, filter, limit, offset, has_more: page.hasMore, ethUsd: usd, launches: page.items }, { headers: { "cache-control": "no-store" } }); + try { + const usd = await ethUsd(); + const page = await listLaunchesPage({ sort, window, chain, filter, limit, offset, ethUsd: usd }); + return NextResponse.json({ sort, window, chain, filter, limit, offset, has_more: page.hasMore, ethUsd: usd, launches: page.items }, { headers: { "cache-control": "no-store" } }); + } catch (err) { + console.error("[launch] list failed:", err instanceof Error ? err.message : err); + return NextResponse.json({ error: "could not load list" }, { status: 502 }); + } } diff --git a/app/src/app/api/launch/live/route.ts b/app/src/app/api/launch/live/route.ts index faa64d5..6f3b699 100644 --- a/app/src/app/api/launch/live/route.ts +++ b/app/src/app/api/launch/live/route.ts @@ -6,6 +6,7 @@ import { VOLUME_WINDOWS, getLaunchFeed, getLaunchTotals, getTrending, isTrending import { ethUsd } from "@/lib/launchpad/ethPrice"; import { memo } from "@/lib/launchpad/memo"; import { listFeed } from "@/lib/launchpad/postsServer"; +import { FEED_POSTS_LIMIT, feedPostsKey } from "@/lib/launchpad/posts-paging"; export const dynamic = "force-dynamic"; @@ -20,18 +21,23 @@ export async function GET(req: Request) { const f = u.searchParams.get("filter"); const filter = isFilter(f) ? f : null; const limit = clampLimit(u.searchParams.get("limit"), 40); - const usd = await ethUsd(); - const listOpts = sort ? { sort, window, chain, filter, limit, offset: 0, ethUsd: usd } : null; - // the home list's first page is the strip's candidate set: rank it instead of running the live query a second time; - // any other view fetches the strip in the same batch as everything else - const deriveTrending = listOpts !== null && isTrendingSource(listOpts); - const [feed, totals, page, posts, fetchedTrending] = await Promise.all([ - memo("feed", 2_000, () => getLaunchFeed(24, usd)), - memo("totals", 2_000, () => getLaunchTotals(usd)), - listOpts ? memo(`list:${chain ?? "all"}:${sort}:${window}:${filter ?? "-"}:${limit}`, 2_000, () => listLaunchesPage(listOpts)) : Promise.resolve(null), - memo("feed-posts:0", 2_000, () => listFeed(30, 0)), - deriveTrending ? Promise.resolve(null) : memo("trending", 2_000, () => getTrending(usd)), - ]); - const trending = fetchedTrending ?? trendingFrom(page?.items ?? []); - return NextResponse.json({ at: Date.now(), feed, totals, ethUsd: usd, sort, window, chain, filter, limit, has_more: page?.hasMore ?? null, launches: page?.items ?? null, posts, trending }, { headers: { "cache-control": "no-store" } }); + try { + const usd = await ethUsd(); + const listOpts = sort ? { sort, window, chain, filter, limit, offset: 0, ethUsd: usd } : null; + // the home list's first page is the strip's candidate set: rank it instead of running the live query a second time; + // any other view fetches the strip in the same batch as everything else + const deriveTrending = listOpts !== null && isTrendingSource(listOpts); + const [feed, totals, page, posts, fetchedTrending] = await Promise.all([ + memo("feed", 2_000, () => getLaunchFeed(24, usd)), + memo("totals", 2_000, () => getLaunchTotals(usd)), + listOpts ? memo(`list:${chain ?? "all"}:${sort}:${window}:${filter ?? "-"}:${limit}`, 2_000, () => listLaunchesPage(listOpts)) : Promise.resolve(null), + memo(feedPostsKey(0), 2_000, () => listFeed(FEED_POSTS_LIMIT, 0)), // the same entry /api/posts?feed=1 serves + deriveTrending ? Promise.resolve(null) : memo("trending", 2_000, () => getTrending(usd)), + ]); + const trending = fetchedTrending ?? trendingFrom(page?.items ?? []); + return NextResponse.json({ at: Date.now(), feed, totals, ethUsd: usd, sort, window, chain, filter, limit, has_more: page?.hasMore ?? null, launches: page?.items ?? null, posts, trending }, { headers: { "cache-control": "no-store" } }); + } catch (err) { + console.error("[launch] live failed:", err instanceof Error ? err.message : err); + return NextResponse.json({ error: "could not load live snapshot" }, { status: 502 }); + } } diff --git a/app/src/app/api/launch/search/route.ts b/app/src/app/api/launch/search/route.ts index 5cfe989..08174a9 100644 --- a/app/src/app/api/launch/search/route.ts +++ b/app/src/app/api/launch/search/route.ts @@ -10,7 +10,12 @@ export async function GET(req: Request) { const u = new URL(req.url); const q = (u.searchParams.get("q") ?? "").slice(0, 80); const c = u.searchParams.get("chain"); - const usd = await ethUsd(); - const launches = await searchLaunches(q, { chain: isChainKey(c) ? c : null, limit: 20, ethUsd: usd }); - return NextResponse.json({ q, launches }, { headers: { "cache-control": "no-store" } }); + try { + const usd = await ethUsd(); + const launches = await searchLaunches(q, { chain: isChainKey(c) ? c : null, limit: 20, ethUsd: usd }); + return NextResponse.json({ q, launches }, { headers: { "cache-control": "no-store" } }); + } catch (err) { + console.error("[launch] search failed:", err instanceof Error ? err.message : err); + return NextResponse.json({ error: "could not search" }, { status: 502 }); + } } diff --git a/app/src/app/api/me/route.ts b/app/src/app/api/me/route.ts index 97e06a9..166e27d 100644 --- a/app/src/app/api/me/route.ts +++ b/app/src/app/api/me/route.ts @@ -10,10 +10,15 @@ export const dynamic = "force-dynamic"; export async function GET(req: Request) { const w = (new URL(req.url).searchParams.get("wallet") ?? "").toLowerCase(); if (!isAddress(w)) return NextResponse.json({ error: "bad wallet" }, { status: 400 }); - const usd = await ethUsd(); - const data = await memo(`me:${w}`, 3_000, async () => { - const [launches, tokens, trades] = await Promise.all([listLaunches({ launcher: w, limit: 200, ethUsd: usd }), getWalletTokens(w, usd), getWalletTrades(w, usd, 50)]); - return { wallet: w, ethUsd: usd, launches, tokens, trades }; - }); - return NextResponse.json(data, { headers: { "cache-control": "no-store" } }); + try { + const usd = await ethUsd(); + const data = await memo(`me:${w}`, 3_000, async () => { + const [launches, tokens, trades] = await Promise.all([listLaunches({ launcher: w, limit: 200, ethUsd: usd }), getWalletTokens(w, usd), getWalletTrades(w, usd, 50)]); + return { wallet: w, ethUsd: usd, launches, tokens, trades }; + }); + return NextResponse.json(data, { headers: { "cache-control": "no-store" } }); + } catch (err) { + console.error("[me] wallet failed:", err instanceof Error ? err.message : err); + return NextResponse.json({ error: "could not load wallet" }, { status: 502 }); + } } diff --git a/app/src/app/api/posts/route.ts b/app/src/app/api/posts/route.ts index fb242cb..b8b35ce 100644 --- a/app/src/app/api/posts/route.ts +++ b/app/src/app/api/posts/route.ts @@ -4,7 +4,7 @@ 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"; +import { FEED_POSTS_LIMIT, feedPostsKey, parseFeedPaging, parseTokenPostsPaging, postsCursorKey } from "@/lib/launchpad/posts-paging"; export const dynamic = "force-dynamic"; @@ -12,14 +12,24 @@ export const dynamic = "force-dynamic"; export async function GET(req: Request) { const u = new URL(req.url); if (u.searchParams.get("feed")) { - const offset = Number(u.searchParams.get("offset") ?? 0) || 0; - return NextResponse.json({ posts: await memo(`feed-posts:${offset}`, 2_000, () => listFeed(30, offset)) }, { headers: { "cache-control": "no-store" } }); + const { offset } = parseFeedPaging({ offset: u.searchParams.get("offset") }); + try { + return NextResponse.json({ posts: await memo(feedPostsKey(offset), 2_000, () => listFeed(FEED_POSTS_LIMIT, offset)) }, { headers: { "cache-control": "no-store" } }); + } catch (err) { + console.error("[posts] feed failed:", err instanceof Error ? err.message : err); + return NextResponse.json({ error: "could not load feed" }, { status: 502 }); + } } 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 }); 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" } }); + try { + return NextResponse.json(await memo(postsCursorKey(chain, token, limit, beforeId), 2_000, () => listTokenPosts(chain, token, limit, beforeId)), { headers: { "cache-control": "no-store" } }); + } catch (err) { + console.error("[posts] token posts failed:", err instanceof Error ? err.message : err); + return NextResponse.json({ error: "could not load comments" }, { status: 502 }); + } } /** 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 index c970ad5..8187e0e 100644 --- a/app/src/lib/launchpad/posts-paging.test.ts +++ b/app/src/lib/launchpad/posts-paging.test.ts @@ -1,6 +1,7 @@ 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"; +import { readFileSync } from "node:fs"; +import { FEED_POSTS_LIMIT, FEED_POSTS_MAX_OFFSET, clampFeedOffset, TOKEN_POSTS_DEFAULT_LIMIT, TOKEN_POSTS_MAX_LIMIT, feedPostsKey, nextPostsCursor, parseFeedPaging, parseTokenPostsPaging, postsCursorKey } from "./posts-paging.ts"; test("parseTokenPostsPaging defaults, clamps and reads the cursor", () => { assert.deepEqual(parseTokenPostsPaging({}), { limit: TOKEN_POSTS_DEFAULT_LIMIT, beforeId: null }); @@ -37,3 +38,33 @@ test("default page preserves existing discussions until the client follows curso 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); }); + +test("parseFeedPaging clamps the offset so bad queries cannot thrash the memo or force huge scans", () => { + assert.deepEqual(parseFeedPaging({}), { offset: 0 }); + assert.deepEqual(parseFeedPaging({ offset: null }), { offset: 0 }, "absent query param uses 0"); + assert.deepEqual(parseFeedPaging({ offset: "" }), { offset: 0 }); + assert.deepEqual(parseFeedPaging({ offset: "abc" }), { offset: 0 }); + assert.deepEqual(parseFeedPaging({ offset: -5 }), { offset: 0 }, "negative collapses to 0"); + assert.deepEqual(parseFeedPaging({ offset: "40" }), { offset: 40 }); + assert.deepEqual(parseFeedPaging({ offset: 1.9 }), { offset: 1 }, "truncates"); + assert.deepEqual(parseFeedPaging({ offset: 9999999999 }), { offset: FEED_POSTS_MAX_OFFSET }, "caps huge offsets"); + assert.equal(FEED_POSTS_LIMIT, 30, "feed page size matches the previous response"); +}); + +test("feedPostsKey normalizes the offset into one memo key", () => { + assert.equal(feedPostsKey(0), "feed-posts:0"); + assert.equal(feedPostsKey(-5), "feed-posts:0"); + assert.equal(feedPostsKey(1.9), "feed-posts:1"); + assert.equal(feedPostsKey(9999999999), `feed-posts:${FEED_POSTS_MAX_OFFSET}`); + assert.equal(feedPostsKey(NaN), "feed-posts:0"); +}); + +test("the feed query clamps with the same helper as the parser and the memo key", () => { + assert.equal(clampFeedOffset(1.9), 1); + assert.equal(clampFeedOffset(Number.POSITIVE_INFINITY), 0); + assert.equal(clampFeedOffset(1e20), FEED_POSTS_MAX_OFFSET, "a huge offset never reaches Postgres as a non-bigint"); + const server = readFileSync(new URL("./postsServer.ts", import.meta.url), "utf8"); + assert.match(server, /OFFSET \$\{clampFeedOffset\(offset\)\}/); + const live = readFileSync(new URL("../../app/api/launch/live/route.ts", import.meta.url), "utf8"); + assert.match(live, /memo\(feedPostsKey\(0\), 2_000, \(\) => listFeed\(FEED_POSTS_LIMIT, 0\)\)/, "live and /api/posts share one feed-posts entry"); +}); diff --git a/app/src/lib/launchpad/posts-paging.ts b/app/src/lib/launchpad/posts-paging.ts index 02894f7..f69240e 100644 --- a/app/src/lib/launchpad/posts-paging.ts +++ b/app/src/lib/launchpad/posts-paging.ts @@ -29,6 +29,25 @@ export function postsCursorKey(chain: string, token: string, limit: number, befo return `posts:${chain}:${token.toLowerCase()}:${limit}:${beforeId ?? "head"}`; } +/** Global human feed page: fixed 30 rows, offset clamped so one bad query cannot thrash the memo or force a huge OFFSET scan. */ +export const FEED_POSTS_LIMIT = 30; +export const FEED_POSTS_MAX_OFFSET = 100_000; + +/** A whole offset in [0, FEED_POSTS_MAX_OFFSET]; anything non-finite is 0. The one clamp the parser, memo key and query share. */ +export function clampFeedOffset(n: number): number { + return Number.isFinite(n) ? Math.min(FEED_POSTS_MAX_OFFSET, Math.max(0, Math.trunc(n))) : 0; +} + +export function parseFeedPaging(query: { offset?: unknown }): { offset: number } { + const blank = query.offset === null || query.offset === undefined || (typeof query.offset === "string" && query.offset.trim() === ""); + return { offset: clampFeedOffset(blank ? NaN : Number(query.offset)) }; +} + +/** Memo key for a feed page. The offset is clamped first so `-5`, `1.9` and `9999999999` cannot each mint a distinct hot key. */ +export function feedPostsKey(offset: number): string { + return `feed-posts:${clampFeedOffset(offset)}`; +} + /** 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; diff --git a/app/src/lib/launchpad/postsServer.ts b/app/src/lib/launchpad/postsServer.ts index 59f4982..68e98f3 100644 --- a/app/src/lib/launchpad/postsServer.ts +++ b/app/src/lib/launchpad/postsServer.ts @@ -5,6 +5,7 @@ import { CHAIN_KEY_PATTERN, DEFAULT_CHAIN, chainIdOf, chainKeyOf, type ChainKey import { maybeDb } from "@/lib/db"; import { ERC20_MIN_ABI } from "./abi"; import { AUTO_HIDE_REPORTS, POST_DAILY_MAX, POST_MIN_GAP_MS, buildModMessage, buildPostMessage, buildReportMessage, canPostNow, holderTag, isReportReason, tsFresh, validateBody, type ModAction, type Tag } from "./posts"; +import { clampFeedOffset } from "./posts-paging"; /** Mute targets: `token::
`. */ const TOKEN_TARGET_RE = new RegExp(`^token:(${CHAIN_KEY_PATTERN}):(0x[0-9a-f]{40})$`); @@ -86,7 +87,7 @@ export async function listFeed(limit = 30, offset = 0): Promise { const rows = await db` SELECT p.id, p.chain_id, p.token, p.wallet, p.parent_id, p.body, p.tag, p.created_at, p.reports, p.hidden, l.symbol, l.name FROM bb_posts p JOIN bb_launches l ON l.chain_id = p.chain_id AND l.token = p.token - WHERE NOT p.hidden AND p.parent_id IS NULL ORDER BY p.created_at DESC LIMIT ${Math.min(100, limit)} OFFSET ${Math.max(0, offset)}`; + WHERE NOT p.hidden AND p.parent_id IS NULL ORDER BY p.created_at DESC LIMIT ${Math.min(100, Math.max(1, Math.trunc(limit) || 30))} OFFSET ${clampFeedOffset(offset)}`; return rows.map(shape); } @@ -218,7 +219,8 @@ export async function moderate(p: { action: unknown; target: unknown; wallet: st ON CONFLICT (chain_id, token) DO UPDATE SET comments_muted = EXCLUDED.comments_muted, updated_at = now()`; } else { const id = Number(p.target.slice(5)); - await db`UPDATE bb_posts SET hidden = ${action === "hide"}, hidden_by = ${action === "hide" ? wallet : null} WHERE id = ${id}`; + const rows = await db<{ id: number }[]>`UPDATE bb_posts SET hidden = ${action === "hide"}, hidden_by = ${action === "hide" ? wallet : null} WHERE id = ${id} RETURNING id`; + if (rows.length === 0) return fail("post not found", 404); } return { ok: true }; }