diff --git a/app/src/lib/launchpad/queries.ts b/app/src/lib/launchpad/queries.ts index fcffad3..43fc871 100644 --- a/app/src/lib/launchpad/queries.ts +++ b/app/src/lib/launchpad/queries.ts @@ -11,7 +11,7 @@ import { SNIPER_BLOCKS } from "./holders"; import { imagePublicBase } from "./imageStore"; import { fdvQuote, quotePerToken, tickToTokensPerQuote, units } from "./math"; import type { RawCandle } from "./candles"; -import { normalizeQuery, isAddressQuery, rankHit, type LaunchFilter } from "./search"; +import { normalizeQuery, isAddressQuery, escapeLike, compareSearchHit, type LaunchFilter } from "./search"; /** * Read model for the UI. Every money field comes in two flavours: raw quote @@ -306,7 +306,7 @@ export async function getLaunch(chain: ChainKey, token: string, ethUsd: number | export async function findLaunchChain(token: string): Promise { const db = maybeDb(); if (!db) return null; - const rows = await db<{ chain_id: number }[]>`SELECT chain_id FROM bb_launches WHERE token = ${token.toLowerCase()} ORDER BY block_number DESC LIMIT 1`; + const rows = await db<{ chain_id: number }[]>`SELECT chain_id FROM bb_launches WHERE token = ${token.toLowerCase()} ORDER BY block_time DESC, chain_id DESC, block_number DESC LIMIT 1`; return rows[0] ? chainKeyOf(rows[0].chain_id) : null; } @@ -430,9 +430,13 @@ export async function searchLaunches(q: string, opts: { chain?: ChainKey | null; const chainCond = opts.chain ? db`AND l.chain_id = ${chainIdOf(opts.chain)}` : db``; const rows = isAddressQuery(n) ? await db`${db.unsafe(SELECT)} WHERE l.token = ${n} ${chainCond}` - : await db`${db.unsafe(SELECT)} WHERE (l.name ILIKE ${"%" + n + "%"} OR l.symbol ILIKE ${"%" + n + "%"}) ${chainCond} ORDER BY l.block_number DESC LIMIT 200`; + // LIKE metacharacters in n are escaped (ESCAPE '\'): a search for "%" or + // "_" matches those literal characters, not every row. Pre-limit orders by + // block time — never raw block numbers across chains (Base heights dwarf + // Robinhood's, so the old ORDER BY hid exact matches on the low chain). + : await db`${db.unsafe(SELECT)} WHERE (l.name ILIKE ${"%" + escapeLike(n) + "%"} ESCAPE '\\' OR l.symbol ILIKE ${"%" + escapeLike(n) + "%"} ESCAPE '\\') ${chainCond} ORDER BY l.block_time DESC, l.chain_id DESC, l.block_number DESC LIMIT 200`; const shaped = rows.map((r) => shape(r, opts.ethUsd ?? null)); - shaped.sort((a, b) => rankHit(a, n) - rankHit(b, n) || b.block_number - a.block_number); + shaped.sort((a, b) => compareSearchHit(a, b, n)); return shaped.slice(0, limit); } diff --git a/app/src/lib/launchpad/search.test.ts b/app/src/lib/launchpad/search.test.ts index 257f16d..274042e 100644 --- a/app/src/lib/launchpad/search.test.ts +++ b/app/src/lib/launchpad/search.test.ts @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { isAddressQuery, matchesFilter, matchesQuery, normalizeQuery, rankHit } from "./search.ts"; +import { compareSearchHit, escapeLike, isAddressQuery, matchesFilter, matchesQuery, normalizeQuery, rankHit, type RankedHit } from "./search.ts"; const row = (o: Partial[0]> = {}) => ({ name: "Clear Sky", @@ -52,3 +52,42 @@ test("rankHit orders symbol exact < symbol prefix < name prefix < substring", () assert.equal(rankHit(row(), "clear"), 2); assert.equal(rankHit(row(), "ear"), 3); }); + +test("escapeLike neutralizes LIKE wildcards and the escape char", () => { + assert.equal(escapeLike("abc"), "abc"); + assert.equal(escapeLike("a%b"), "a\\%b"); + assert.equal(escapeLike("a_b"), "a\\_b"); + assert.equal(escapeLike("a\\b"), "a\\\\b"); + assert.equal(escapeLike("%_%"), "\\%\\_\\%"); +}); + +const hit = (o: Partial = {}): RankedHit => ({ + ...row(), + chain_id: 8453, + block_number: 30_000_000, + ...o, +}); + +test("compareSearchHit: relevance first, then cross-chain newest-first", () => { + const exactLowChain = hit({ symbol: "FOO", chain_id: 7777777, block_number: 5_000, block_time: "2026-09-11T23:59:00.000Z" }); + const substringHighChain = hit({ symbol: "XFOOX", name: "Xfoox thing", chain_id: 8453, block_number: 30_000_000, block_time: "2026-09-12T00:00:00.000Z" }); + assert.ok(compareSearchHit(exactLowChain, substringHighChain, "foo") < 0, "exact match on a low-height chain beats a newer substring match"); + assert.ok(compareSearchHit(substringHighChain, exactLowChain, "foo") > 0); +}); + +test("compareSearchHit: same rank orders by block time, not raw block number", () => { + const olderHighHeight = hit({ symbol: "FOOA", chain_id: 8453, block_number: 30_000_000, block_time: "2026-09-11T00:00:00.000Z" }); + const newerLowHeight = hit({ symbol: "FOOB", chain_id: 7777777, block_number: 5_000, block_time: "2026-09-12T00:00:00.000Z" }); + assert.ok(compareSearchHit(newerLowHeight, olderHighHeight, "foo") < 0, "newer launch wins despite a far lower block number"); + const sorted = [olderHighHeight, newerLowHeight].sort((a, b) => compareSearchHit(a, b, "foo")); + assert.deepEqual(sorted.map((r) => r.symbol), ["FOOB", "FOOA"]); +}); + +test("compareSearchHit: rank dominates time; ties break deterministically", () => { + const newerSubstring = hit({ symbol: "XFOOX", name: "Xfoox", block_time: "2026-09-12T00:00:00.000Z", block_number: 30_000_001 }); + const olderPrefix = hit({ symbol: "FOOX", block_time: "2026-09-11T00:00:00.000Z", block_number: 30_000_000 }); + assert.ok(compareSearchHit(olderPrefix, newerSubstring, "foo") < 0, "prefix beats newer substring"); + const a = hit({ symbol: "FOO", block_time: "2026-09-12T00:00:00.000Z", chain_id: 8453, block_number: 7 }); + const b = hit({ symbol: "FOO", block_time: "2026-09-12T00:00:00.000Z", chain_id: 8453, block_number: 7 }); + assert.equal(compareSearchHit(a, b, "foo"), 0); +}); diff --git a/app/src/lib/launchpad/search.ts b/app/src/lib/launchpad/search.ts index 4937adc..48b935c 100644 --- a/app/src/lib/launchpad/search.ts +++ b/app/src/lib/launchpad/search.ts @@ -1,4 +1,5 @@ /** Pure search / filter helpers (client + server; node --test loads this directly). */ +import { newestFirst } from "./paging"; export type LaunchFilter = "fee0" | "burn" | "usdg" | "gitlawb" | "today"; /** `chain`: the filter only makes sense on that chain (its quote is not offered elsewhere) → hidden when another chain is selected. */ export const FILTERS: { key: LaunchFilter; label: string; title: string; chain?: "base" | "robinhood" }[] = [ @@ -50,3 +51,25 @@ export function rankHit(l: Matchable, q: string): number { if (name.startsWith(n)) return 2; return 3; } + +/** + * Escape user input for a Postgres LIKE/ILIKE pattern (`%`, `_` and the escape + * char itself). Without this, searching for `%` or `_` matches nearly every + * row instead of the literal character. + */ +export function escapeLike(s: string): string { + return s.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_"); +} + +/** A search hit with the cross-chain ordering keys (block numbers are only comparable within one chain). */ +export type RankedHit = Matchable & { chain_id: number; block_number: number }; + +/** + * Full search ordering: relevance rank first, then cross-chain newest-first + * (block time, chain id, block number — never raw block numbers across + * chains, mirroring the list sorts). The SQL pre-limit must use the same key + * or exact matches on a low-height chain never reach the ranker. + */ +export function compareSearchHit(a: RankedHit, b: RankedHit, q: string): number { + return rankHit(a, q) - rankHit(b, q) || newestFirst(a, b); +}