diff --git a/app/db/concurrent-indexes.sql b/app/db/concurrent-indexes.sql index 65ba506c..fe9157db 100644 --- a/app/db/concurrent-indexes.sql +++ b/app/db/concurrent-indexes.sql @@ -20,3 +20,7 @@ CREATE INDEX CONCURRENTLY IF NOT EXISTS bb_launches_chain_token_id_idx ON bb_lau -- trade-time USD (2026-11-10): the trades still without a USD value (every one until the first backfill, then only -- those whose hour had no price yet), so a repeat backfill and the "partial" check never scan every swap CREATE INDEX CONCURRENTLY IF NOT EXISTS bb_launch_swaps_usd_missing_idx ON bb_launch_swaps (chain_id, block_time) WHERE usd IS NULL; +-- trade-time USD review (2026-11-11): the lists' per-launch probe of trades with no USD value (queries.SELECT's LATERAL u), and +-- the site totals' read of quote-side fees with no USD value (getLaunchTotals): neither scans its whole table on a page view +CREATE INDEX CONCURRENTLY IF NOT EXISTS bb_launch_swaps_usd_missing_token_idx ON bb_launch_swaps (chain_id, token) WHERE usd IS NULL; +CREATE INDEX CONCURRENTLY IF NOT EXISTS bb_launch_fee_events_usd_missing_idx ON bb_launch_fee_events (chain_id, token) WHERE usd IS NULL AND kind IN ('collected', 'burned'); diff --git a/app/scripts/rebuild-launch-totals.sql b/app/scripts/rebuild-launch-totals.sql index 681175e5..73e9820e 100644 --- a/app/scripts/rebuild-launch-totals.sql +++ b/app/scripts/rebuild-launch-totals.sql @@ -20,6 +20,12 @@ SELECT 'drift before' AS what, count(*) FILTER (WHERE l.buys <> s.buys OR l.sells <> s.sells OR l.volume_quote <> s.volume_quote) AS launches_with_wrong_totals, count(*) FILTER (WHERE (l.buys + l.sells) > 0 AND s.token IS NULL) AS totals_without_swaps FROM bb_launches l LEFT JOIN s ON s.chain_id = l.chain_id AND s.token = l.token; +-- trade-time USD totals vs the sums of their stored per-event values (lib/launchpad/trade-time-usd.ts) +SELECT 'usd drift before' AS what, + count(*) FILTER (WHERE abs(l.volume_usd - COALESCE((SELECT sum(s.usd) FROM bb_launch_swaps s WHERE s.chain_id = l.chain_id AND s.token = l.token), 0)) > 0.01 + OR abs(l.fees_collected_usd - COALESCE((SELECT sum(f.usd) FROM bb_launch_fee_events f WHERE f.chain_id = l.chain_id AND f.token = l.token AND f.kind = 'collected'), 0)) > 0.01 + OR abs(l.fees_burned_usd - COALESCE((SELECT sum(f.usd) FROM bb_launch_fee_events f WHERE f.chain_id = l.chain_id AND f.token = l.token AND f.kind = 'burned' AND f.currency = l.quote), 0)) > 0.01) AS launches_with_wrong_usd_totals + FROM bb_launches l; -- swaps → buys / sells / volume / last trade UPDATE bb_launches l SET @@ -51,6 +57,12 @@ UPDATE bb_launches l SET (SELECT COALESCE(sum(amount), 0) FROM bb_launch_fee_events e WHERE e.chain_id = l2.chain_id AND e.token = l2.token AND e.kind = 'burned' AND e.currency <> l2.quote) AS tb FROM bb_launches l2) f WHERE l.chain_id = f.chain_id AND l.token = f.token; +-- trade-time USD totals: the sums of the stored per-event values (rows with no value yet stay out; the indexer's heal pass +-- and scripts/usd-backfill.ts price those) +UPDATE bb_launches l SET + volume_usd = COALESCE((SELECT sum(s.usd) FROM bb_launch_swaps s WHERE s.chain_id = l.chain_id AND s.token = l.token), 0), + fees_collected_usd = COALESCE((SELECT sum(f.usd) FROM bb_launch_fee_events f WHERE f.chain_id = l.chain_id AND f.token = l.token AND f.kind = 'collected'), 0), + fees_burned_usd = COALESCE((SELECT sum(f.usd) FROM bb_launch_fee_events f WHERE f.chain_id = l.chain_id AND f.token = l.token AND f.kind = 'burned' AND f.currency = l.quote), 0); WITH s AS ( SELECT chain_id, token, count(*) FILTER (WHERE is_buy) AS buys, count(*) FILTER (WHERE NOT is_buy) AS sells, COALESCE(sum(abs(amount0)), 0) AS volume_quote @@ -59,4 +71,10 @@ WITH s AS ( SELECT 'drift after' AS what, count(*) FILTER (WHERE l.buys <> COALESCE(s.buys, 0) OR l.sells <> COALESCE(s.sells, 0) OR l.volume_quote <> COALESCE(s.volume_quote, 0)) AS launches_with_wrong_totals FROM bb_launches l LEFT JOIN s ON s.chain_id = l.chain_id AND s.token = l.token; +-- trade-time USD totals vs the sums of their stored per-event values (lib/launchpad/trade-time-usd.ts) +SELECT 'usd drift after' AS what, + count(*) FILTER (WHERE abs(l.volume_usd - COALESCE((SELECT sum(s.usd) FROM bb_launch_swaps s WHERE s.chain_id = l.chain_id AND s.token = l.token), 0)) > 0.01 + OR abs(l.fees_collected_usd - COALESCE((SELECT sum(f.usd) FROM bb_launch_fee_events f WHERE f.chain_id = l.chain_id AND f.token = l.token AND f.kind = 'collected'), 0)) > 0.01 + OR abs(l.fees_burned_usd - COALESCE((SELECT sum(f.usd) FROM bb_launch_fee_events f WHERE f.chain_id = l.chain_id AND f.token = l.token AND f.kind = 'burned' AND f.currency = l.quote), 0)) > 0.01) AS launches_with_wrong_usd_totals + FROM bb_launches l; COMMIT; diff --git a/app/scripts/usd-backfill.ts b/app/scripts/usd-backfill.ts index 66758d9e..df9faa3e 100644 --- a/app/scripts/usd-backfill.ts +++ b/app/scripts/usd-backfill.ts @@ -34,11 +34,19 @@ const out = args.get("out") || `usd-backfill.${mode}.sql`; const UA = { "user-agent": "openlaunch-usd-backfill" }; const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); +/** GET/POST JSON with retries on 429, 5xx, network errors and timeouts (a transient reset must not abort a minutes-long run). */ async function getJson(url: string, init?: RequestInit, tries = 4): Promise { for (let i = 0; ; i++) { - const res = await fetch(url, { ...init, headers: { ...UA, ...(init?.headers ?? {}) }, signal: AbortSignal.timeout(20_000) }); - if (res.ok) return res.json(); - if (i + 1 >= tries || (res.status !== 429 && res.status < 500)) throw new Error(`${url.split("?")[0]}: HTTP ${res.status}`); + let status: number | null = null; + try { + const res = await fetch(url, { ...init, headers: { ...UA, ...(init?.headers ?? {}) }, signal: AbortSignal.timeout(20_000) }); + if (res.ok) return await res.json(); + status = res.status; + if (status !== 429 && status < 500) throw Object.assign(new Error(`${url.split("?")[0]}: HTTP ${status}`), { final: true }); + } catch (err) { + if ((err as { final?: boolean }).final || i + 1 >= tries) throw err; + } + if (i + 1 >= tries) throw new Error(`${url.split("?")[0]}: HTTP ${status}`); await sleep(2_000 * (i + 1)); } } diff --git a/app/src/components/launchpad/LaunchMechanism.tsx b/app/src/components/launchpad/LaunchMechanism.tsx index a3aa5597..fbdfc154 100644 --- a/app/src/components/launchpad/LaunchMechanism.tsx +++ b/app/src/components/launchpad/LaunchMechanism.tsx @@ -35,13 +35,13 @@ export default function LaunchMechanism() { // totals roll to their new value when a launch or trade lands (static on first paint and under reduced motion) const count = (value: number) => ; // some quote has no price right now: the dollar sums undercount, so say "≈" instead of showing a confident smaller number - const usdNote = t.usd_partial ? "Some launches are quoted in an asset with no USD price right now; dollar totals exclude them until it returns." : undefined; + const usdNote = t.usd_partial ? "Some trades have no recorded USD value and their quote asset has no USD price right now; dollar totals leave them out until it returns." : undefined; // `live` figures roll as they change (compact); the rest stay exact text. Both carry the same "≈" guard. const usd = (v: number, live = false) => (live ? : `${t.usd_partial ? "≈" : ""}${fmtUsd(v)}`); // the compact figure rounds, so its tooltip carries the exact sum and says what kind of number it is - // trade-time USD (lib/launchpad/trade-time-usd.ts): each trade and fee at the hour it happened; trades in a quote with no - // price history (registry stocks, community pairs and unlisted tokens before Nov 10, 2026) at today's rate - const usdTitle = (v: number, what: string) => usdNote ?? `${fmtUsd(v)} · ${what}, each valued in USD at the hour it happened (stock and community-pair trades before Nov 10, 2026 at today's rate)`; + // trade-time USD (lib/launchpad/trade-time-usd.ts): each trade and fee at the hour it happened; the ones with no recorded + // value (community pairs, stocks before Nov 10, 2026, an hour not priced yet) at today's rate; unlisted tokens have no USD + const usdTitle = (v: number, what: string) => usdNote ?? `${fmtUsd(v)} · ${what}, each valued in USD at the hour it happened (community-pair trades, and any without a recorded value, at today's rate)`; const usdChars = USD_CHARS + (t.usd_partial ? 1 : 0); const gitlawbBurnedExact = `${fmtUnitsExact(t.gitlawb_burned, GITLAWB_DECIMALS)} ${GITLAWB_SYMBOL}`; // every digit of the raw amount, no float // TWIG burned: its own figure (the GITLAWB behind it stays in the TWIG contract), shown once there is any; null-safe for a poll from an older machine diff --git a/app/src/components/launchpad/hero-layout.test.ts b/app/src/components/launchpad/hero-layout.test.ts index ad8d5547..391386f8 100644 --- a/app/src/components/launchpad/hero-layout.test.ts +++ b/app/src/components/launchpad/hero-layout.test.ts @@ -141,7 +141,7 @@ test("the dollar readings are latched: shown totals change only when something h test("the two dollar figures explain themselves, and the ≈ note is readable without a mouse", () => { // trade-time USD: each figure says it is valued at the hour each trade happened, and names the part that is not - assert.match(totals, /usdNote \?\? `\$\{fmtUsd\(v\)\} · \$\{what\}, each valued in USD at the hour it happened \(stock and community-pair trades before Nov 10, 2026 at today's rate\)`/); + assert.match(totals, /usdNote \?\? `\$\{fmtUsd\(v\)\} · \$\{what\}, each valued in USD at the hour it happened \(community-pair trades, and any without a recorded value, at today's rate\)`/); assert.doesNotMatch(totals, /valued at current rates/); assert.match(totals, /title=\{usdTitle\(t\.volume_usd, "quote volume"\)\}/); assert.match(totals, /title=\{usdTitle\(t\.fees_to_creators_usd, "quote-asset fees"\)\}/, "fees are not volume"); diff --git a/app/src/lib/launchpad/indexer.ts b/app/src/lib/launchpad/indexer.ts index dba713ec..fcecb35a 100644 --- a/app/src/lib/launchpad/indexer.ts +++ b/app/src/lib/launchpad/indexer.ts @@ -15,7 +15,7 @@ import { TWIG_ADDRESSES } from "./twig"; import { boostFromTransfer } from "./boost"; import { pairBurnFromTransfer } from "./pairApply"; import { recordPairBurn } from "./pairApplyServer"; -import { captureLiveUsd } from "./usdLedger"; +import { captureLiveUsd, healUnpricedUsd } from "./usdLedger"; import { hourIso } from "./usd-history"; /** @@ -859,6 +859,8 @@ async function run(chain: ChainKey): Promise { from = to + 1n; chunks++; } + // trades and fees whose hour has a price by now but had none when they were written (usdLedger.ts; throttled, never throws) + await healUnpricedUsd(db, chain); return { status: "synced", from: from.toString(), to: to.toString(), head: head.toString(), ...totals, caught_up: from > confirmed }; } catch (err) { const msg = errMessage(err); @@ -901,7 +903,8 @@ export async function applyLaunchTx(chain: ChainKey, hash: Hex): Promise l.address.toLowerCase() === pm) }); for (const l of swapLogs) { if (await applySwap(db, chain, l as unknown as Log & { args: Swap }, poolToToken, tokenQuote)) { diff --git a/app/src/lib/launchpad/queries.ts b/app/src/lib/launchpad/queries.ts index d8ac85c5..8880e686 100644 --- a/app/src/lib/launchpad/queries.ts +++ b/app/src/lib/launchpad/queries.ts @@ -21,7 +21,7 @@ import { collectNonDust } from "./feed-dust"; import { PIPS_LEADER, PIPS_RUNNER, pipFromSwapRow, tapeOrNone, type Pip } from "./trending-live"; import { launchKey } from "./list-state"; import { fdvQuote, quotePerToken, tickToTokensPerQuote, units } from "./math"; -import { UNPRICED_SWAPS_SQL, missingNow, tradeTimeUsd } from "./trade-time-usd"; +import { UNPRICED_LATERAL_SQL, launchVolumeUsd, missingNow, tradeTimeUsd } from "./trade-time-usd"; import type { RawCandle } from "./candles"; import { normalizeQuery, isAddressQuery, escapeLike, compareSearchHit, compareSearchHitByHolders, type LaunchFilter } from "./search"; @@ -244,7 +244,7 @@ function shape(raw: Raw & TradeTimeCols & { last_swap_block?: bigint; last_swap_ price_usd: qu === null ? null : price * qu, fdv_usd: qu === null ? null : fdv * qu, // all-time volume at each trade's own hour; trades with no stored value (no price history) at today's price - volume_usd: tradeTimeUsd(storedVolumeUsd, volume_quote_unpriced, q.decimals, qu), + volume_usd: launchVolumeUsd(storedVolumeUsd, volume_quote_unpriced, q.decimals, qu), volume_1h_usd: usd(String(r.volume_1h ?? "0")), volume_24h_usd: usd(String(r.volume_24h ?? "0")), }; @@ -264,14 +264,14 @@ export const SELECT = `SELECT l.*, m.description, m.image_url, m.banner_url, m.w COALESCE(u.uq, 0)::text AS volume_quote_unpriced FROM bb_launches l LEFT JOIN bb_launch_meta m ON m.chain_id = l.chain_id AND m.token = l.token - LEFT JOIN (${UNPRICED_SWAPS_SQL}) u ON u.chain_id = l.chain_id AND u.token = l.token LEFT JOIN LATERAL ( SELECT count(*) FILTER (WHERE ${HOUR})::int AS n1, count(DISTINCT s.trader) FILTER (WHERE ${HOUR})::int AS t1, count(DISTINCT s.trader) FILTER (WHERE ${HOUR} AND ${OUTSIDE})::int AS t1_ex, COALESCE(sum(abs(amount0)) FILTER (WHERE ${HOUR}), 0)::text AS v1, count(*)::int AS n24, count(DISTINCT s.trader) FILTER (WHERE ${OUTSIDE})::int AS t24_ex, COALESCE(sum(abs(amount0)), 0)::text AS v24, max(s.block_time) FILTER (WHERE ${OUTSIDE}) AS last_outside_at FROM bb_launch_swaps s WHERE s.chain_id = l.chain_id AND s.token = l.token AND s.block_time > now() - interval '${LIVE_WINDOW_HOURS} hours' - ) w ON true`; + ) w ON true + LEFT JOIN LATERAL (${UNPRICED_LATERAL_SQL}) u ON true`; export type LaunchSort = "live" | "new" | "mcap" | "volume" | "gainers" | "holders"; export type VolumeWindow = "1h" | "24h" | "all"; @@ -858,12 +858,12 @@ export async function getTrending(ethUsd: number | null = null): Promise { decimals: number; decimalsKnown: boolean; usd: number | null }> { +export async function quoteUsdNow(ethUsd: number | null): Promise<(chain: ChainKey, quote: string) => { key: Quote["key"]; decimals: number; decimalsKnown: boolean; usd: number | null }> { await withStocks(); const snap = priceState(); return (chain, quote) => { const q = quoteInfo(chain, quote, snap); - return { decimals: q.decimals, decimalsKnown: q.decimalsKnown !== false, usd: quoteUsd(q, ethUsd) }; + return { key: q.key, decimals: q.decimals, decimalsKnown: q.decimalsKnown !== false, usd: quoteUsd(q, ethUsd) }; }; } diff --git a/app/src/lib/launchpad/trade-time-usd.test.ts b/app/src/lib/launchpad/trade-time-usd.test.ts index 5ca90d49..127d951e 100644 --- a/app/src/lib/launchpad/trade-time-usd.test.ts +++ b/app/src/lib/launchpad/trade-time-usd.test.ts @@ -2,7 +2,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import path from "node:path"; -import { UNPRICED_SWAPS_SQL, missingNow, tradeTimeUsd } from "./trade-time-usd.ts"; +import { UNPRICED_LATERAL_SQL, UNPRICED_SWAPS_SQL, launchVolumeUsd, missingNow, tradeTimeUsd } from "./trade-time-usd.ts"; const ETH = 10n ** 18n; @@ -24,6 +24,13 @@ test("trade-time USD = the stored sum, plus what has no stored value at today's assert.equal(tradeTimeUsd(Number.NaN, "0", 18, 3), 0, "a bad stored value is never shown"); }); +test("a launch row shows its quote amount, not a smaller dollar figure, while part of its volume has no price at all", () => { + assert.equal(launchVolumeUsd(500, (2n * ETH).toString(), 18, null), null, "ETH price down + 2 ETH unpriced: no dollar figure that looks complete"); + assert.equal(launchVolumeUsd(500, (2n * ETH).toString(), 18, 2_000), 4_500); + assert.equal(launchVolumeUsd(500, "0", 18, null), 500, "everything has its trade-time value: no price needed now"); + assert.equal(launchVolumeUsd(0, "0", 18, null), null); +}); + test("missingNow flags only a part that normally has a price but has none right now", () => { assert.equal(missingNow(ETH.toString(), null, true), true); assert.equal(missingNow(ETH.toString(), 2_000, true), false); @@ -34,6 +41,11 @@ test("missingNow flags only a part that normally has a price but has none right test("the unpriced read only touches rows with no stored value, per launch", () => { assert.match(UNPRICED_SWAPS_SQL, /FROM bb_launch_swaps WHERE usd IS NULL GROUP BY chain_id, token$/); + // the lists probe one launch at a time (bb_launch_swaps_usd_missing_token_idx), never aggregate every unpriced trade + assert.equal(UNPRICED_LATERAL_SQL, "SELECT sum(abs(x.amount0)) AS uq FROM bb_launch_swaps x WHERE x.chain_id = l.chain_id AND x.token = l.token AND x.usd IS NULL"); + const idx = readFileSync(path.join(process.cwd(), "db/concurrent-indexes.sql"), "utf8"); + assert.match(idx, /bb_launch_swaps_usd_missing_token_idx ON bb_launch_swaps \(chain_id, token\) WHERE usd IS NULL;/); + assert.match(idx, /bb_launch_fee_events_usd_missing_idx ON bb_launch_fee_events \(chain_id, token\) WHERE usd IS NULL AND kind IN \('collected', 'burned'\);/); assert.match(UNPRICED_SWAPS_SQL, /sum\(abs\(amount0\)\) AS uq/, "buys and sells both count at their size, as volume_quote does"); }); @@ -41,9 +53,9 @@ test("the site reads trade-time USD everywhere the all-time figures are shown", const q = readFileSync(path.join(process.cwd(), "src/lib/launchpad/queries.ts"), "utf8"); // the list SELECT carries each launch's unpriced volume, joined once assert.match(q, /COALESCE\(u\.uq, 0\)::text AS volume_quote_unpriced/); - assert.match(q, /LEFT JOIN \(\$\{UNPRICED_SWAPS_SQL\}\) u ON u\.chain_id = l\.chain_id AND u\.token = l\.token/); + assert.match(q, /LEFT JOIN LATERAL \(\$\{UNPRICED_LATERAL_SQL\}\) u ON true`;/); // a launch row's all-time volume - assert.match(q, /volume_usd: tradeTimeUsd\(storedVolumeUsd, volume_quote_unpriced, q\.decimals, qu\)/); + assert.match(q, /volume_usd: launchVolumeUsd\(storedVolumeUsd, volume_quote_unpriced, q\.decimals, qu\)/); assert.doesNotMatch(q, /volume_usd: usd\(r\.volume_quote\)/, "never quote volume at today's price again"); // the all-time Volume sort ranks by the same figure assert.match(q, /win === "all"\s*\? db`\(l\.volume_usd \+ COALESCE\(u\.uq, 0\) \/ power\(10, \$\{qd\}\) \* \$\{usdPerUnit\}\)`/); diff --git a/app/src/lib/launchpad/trade-time-usd.ts b/app/src/lib/launchpad/trade-time-usd.ts index 9ead228d..c223f302 100644 --- a/app/src/lib/launchpad/trade-time-usd.ts +++ b/app/src/lib/launchpad/trade-time-usd.ts @@ -1,14 +1,21 @@ /** * Reading trade-time USD (pure; node --test loads it). Every trade and quote-side fee carries its USD value at its own * hour (bb_launch_swaps.usd, bb_launch_fee_events.usd; usd-history.ts), summed per launch in bb_launches.volume_usd, - * fees_collected_usd and fees_burned_usd. A row with no stored value is one whose quote has no price history (registry - * stocks, community pairs and unlisted tokens traded before 2026-11-10, when the live capture started): it counts at - * the quote's price now, the old method, so the totals stay complete. The site says so where the figures are shown. + * fees_collected_usd and fees_burned_usd. A row with no stored value counts at the quote's price now, the old method, so + * the totals stay complete: community pairs (never stored: an outside pool's price can be pushed for a minute, and a + * suspended or delisted pair must show no USD at all, usdLedger.ts), registry stocks traded before 2026-11-10 (no + * price history), and any hour the indexer could not price yet (its heal pass fills those). An unlisted token has no + * price at all: its trades carry no USD value, as before. The site says so where the figures are shown. */ import { units } from "./math.ts"; -/** Swaps with no stored USD value, summed per launch in raw quote units (the partial index bb_launch_swaps_usd_missing_idx keeps it small). */ +/** Swaps with no stored USD value, summed per launch in raw quote units: the site totals' one grouped read (bb_launch_swaps_usd_missing_idx). */ export const UNPRICED_SWAPS_SQL = `SELECT chain_id, token, sum(abs(amount0)) AS uq FROM bb_launch_swaps WHERE usd IS NULL GROUP BY chain_id, token`; +/** + * The same, for one launch at a time (the lists' LATERAL join): a probe of bb_launch_swaps_usd_missing_token_idx per row, + * so a list of 40 launches reads 40 tiny index ranges instead of aggregating every unpriced trade on every request. + */ +export const UNPRICED_LATERAL_SQL = `SELECT sum(abs(x.amount0)) AS uq FROM bb_launch_swaps x WHERE x.chain_id = l.chain_id AND x.token = l.token AND x.usd IS NULL`; /** * A USD total: the stored trade-time sum plus the raw quote amount with no stored value at `quoteUsdNow`. @@ -21,6 +28,15 @@ export function tradeTimeUsd(storedUsd: number | null | undefined, unpricedRaw: return stored > 0 || quoteUsdNow !== null ? stored : null; } +/** + * One launch's all-time USD for its row: as tradeTimeUsd, except that a part with no stored value and no price right now + * makes the whole figure null, so the row shows its quote amount instead of a smaller dollar figure that looks complete. + */ +export function launchVolumeUsd(storedUsd: number | null | undefined, unpricedRaw: string | bigint | null | undefined, decimals: number, quoteUsdNow: number | null): number | null { + if (quoteUsdNow === null && unpricedRaw !== null && unpricedRaw !== undefined && BigInt(unpricedRaw) > 0n) return null; + return tradeTimeUsd(storedUsd, unpricedRaw, decimals, quoteUsdNow); +} + /** Whether a total undercounts right now: a part with no stored value whose quote normally has a price but has none at the moment. */ export function missingNow(unpricedRaw: string | bigint | null | undefined, quoteUsdNow: number | null, priced: boolean): boolean { if (unpricedRaw === null || unpricedRaw === undefined) return false; diff --git a/app/src/lib/launchpad/usd-history.test.ts b/app/src/lib/launchpad/usd-history.test.ts index 9430c813..593fb09a 100644 --- a/app/src/lib/launchpad/usd-history.test.ts +++ b/app/src/lib/launchpad/usd-history.test.ts @@ -112,22 +112,33 @@ test("a dry run writes nothing: it reports and rolls back", () => { // the ledger fill never replaces an hour the indexer captured live assert.match(s, /INSERT INTO bb_quote_usd_hourly \(chain_id, quote, hour, usd, decimals, source\)[\s\S]*?ON CONFLICT \(chain_id, quote, hour\) DO NOTHING;/); assert.match(s, /\('ETH-USD', '2026-09-06T00:00:00\.000Z', 2490\.40000000000, 'coinbase'\)/); + // community pairs are never stored: the report counts them as unpriced, whatever an earlier version captured + assert.equal(s.match(/CASE WHEN EXISTS \(SELECT 1 FROM bb_pairs pr WHERE pr\.chain_id = l\.chain_id AND pr\.address = l\.quote\) THEN NULL/g)?.length, 3, "per-quote report, total, fees"); }); -test("apply prices only rows with no USD yet, at their own hour, then recomputes totals under the rebuild script's locks", () => { +test("apply is ONE transaction: under the rebuild script's locks it clears pair values, prices rows with no USD yet at their own hour, recomputes totals", () => { const s = sql("apply"); - const updates = s.match(/UPDATE bb_launch_(swaps|fee_events) [\s\S]*?;/g) ?? []; - assert.equal(updates.length, 3, "trades, collected fees, burned fees"); - for (const u of updates) { + assert.equal(s.match(/^BEGIN;$/gm)?.length, 1); + assert.equal(s.match(/^COMMIT;$/gm)?.length, 1, "no reader ever sees rows priced but not yet in the stored sums"); + const lockAt = s.indexOf("LOCK TABLE bb_launch_swaps IN SHARE MODE"); + assert.match(s, /LOCK TABLE bb_launch_fee_events IN SHARE MODE;\nLOCK TABLE bb_launches IN EXCLUSIVE MODE;/); + const writes = [...s.matchAll(/^(UPDATE bb_launch_swaps|UPDATE bb_launch_fee_events|UPDATE bb_launches|DELETE FROM bb_quote_usd_hourly)\b/gm)]; + assert.equal(writes.length, 7, "2 pair clears, 1 pair ledger delete, 3 pricing updates, 1 recompute"); + for (const w of writes) assert.ok(w.index! > lockAt, `${w[1]} runs under the locks`); + const pricing = s.match(/^UPDATE bb_launch_(swaps|fee_events) \w SET usd = (?!NULL)[\s\S]*?;/gm) ?? []; + assert.equal(pricing.length, 3, "trades, collected fees, burned fees"); + for (const u of pricing) { assert.match(u, /\.usd IS NULL/, "never re-prices a row that has a value"); assert.match(u, /p\.hour = to_timestamp\(floor\(extract\(epoch FROM [sf]\.block_time\) \/ 3600\) \* 3600\)/, "its own hour"); } - assert.match(updates[2], /f\.kind = 'burned' AND f\.currency = l\.quote/, "a burn of the launched token is never priced as the quote"); - const lockAt = s.indexOf("LOCK TABLE bb_launch_swaps IN SHARE MODE"); - const recomputeAt = s.indexOf("UPDATE bb_launches l SET"); - assert.ok(lockAt > 0 && recomputeAt > lockAt, "the totals are recomputed only under the locks"); - assert.match(s, /LOCK TABLE bb_launches IN EXCLUSIVE MODE/); - assert.equal(s.match(/^COMMIT;$/gm)?.length, 2); + assert.match(pricing[2], /f\.kind = 'burned' AND f\.currency = l\.quote/, "a burn of the launched token is never priced as the quote"); + // community pairs: their stored values and ledger rows go, before anything is priced + const clears = s.match(/^UPDATE bb_launch_(swaps|fee_events) \w SET usd = NULL[\s\S]*?;/gm) ?? []; + assert.equal(clears.length, 2); + for (const c of clears) assert.match(c, /usd IS NOT NULL[\s\S]*EXISTS \(SELECT 1 FROM bb_pairs pr WHERE pr\.chain_id = l\.chain_id AND pr\.address = l\.quote\)/); + assert.match(s, /DELETE FROM bb_quote_usd_hourly h WHERE EXISTS \(SELECT 1 FROM bb_pairs pr WHERE pr\.chain_id = h\.chain_id AND pr\.address = h\.quote\);/); + assert.ok(s.indexOf("SET usd = NULL") < s.indexOf("SET usd = abs("), "pairs are cleared before pricing (so they are never re-priced)"); + assert.ok(s.indexOf("UPDATE bb_launches l SET") > s.lastIndexOf("SET usd = f.amount"), "totals are recomputed after every row is priced"); assert.match(s, /AFTER APPLY/); }); @@ -147,11 +158,29 @@ test("the indexer prices each trade and fee at its own hour, in the same transac const fee = src.slice(src.indexOf("async function applyFeeIn("), src.indexOf("async function poolMaps(")); assert.match(fee, /'collected'[\s\S]*FROM bb_quote_usd_hourly p[\s\S]*fees_collected_usd = fees_collected_usd \+ \$\{usd\}::float8/); assert.match(fee, /const priceIn = cur === info\.quote \? cur : "";[\s\S]*p\.quote = \$\{priceIn\}[\s\S]*fees_burned_usd = fees_burned_usd \+ \$\{usd\}::float8/); - // this hour's live prices are captured before any trade in the range is priced, on both ingestion paths - for (const fn of ["async function applyRange(", "export async function applyLaunchTx("]) { - const body = src.slice(src.indexOf(fn), src.indexOf(fn) + 4000); - const capture = body.indexOf("await captureLiveUsd(db, chain, tokenQuote.values())"); - const swaps = body.indexOf("applySwap(db, chain,"); - assert.ok(capture > 0 && swaps > capture, `${fn}: capture before swaps`); - } + // the poller captures this hour's live prices before any trade in its range is priced + const range = src.slice(src.indexOf("async function applyRange("), src.indexOf("async function applyRange(") + 4000); + const capture = range.indexOf("await captureLiveUsd(db, chain, tokenQuote.values())"); + assert.ok(capture > 0 && range.indexOf("applySwap(db, chain,") > capture, "applyRange: capture before swaps"); + // the receipt path (the user is waiting) never fetches prices: the poller's heal pass prices what it could not + const receipt = src.slice(src.indexOf("export async function applyLaunchTx(")); + assert.doesNotMatch(receipt, /captureLiveUsd\(/); + const run = src.slice(src.indexOf("async function run(chain: ChainKey)"), src.indexOf("export async function applyLaunchTx(")); + assert.match(run, /await healUnpricedUsd\(db, chain\);\n\s*return \{ status: "synced"/, "the heal pass runs after every sync, outside the chunk loop"); +}); + +test("the live capture never stores a community pair (or an unlisted token); the heal pass skips pairs and changes each row once", async () => { + const { capturable } = await import("./usdLedger.ts"); + for (const key of ["eth", "hype", "usdc", "usdg", "gitlawb", "twig", "stock"]) assert.equal(capturable(key), true, key); + assert.equal(capturable("pair"), false, "an outside pool can be pushed for a minute, and a delisted pair must show no USD"); + assert.equal(capturable("other"), false); + const led = readFileSync(path.join(process.cwd(), "src/lib/launchpad/usdLedger.ts"), "utf8"); + assert.match(led, /return capturable\(p\.key\) && p\.usd !== null/); + const heal = led.slice(led.indexOf("export async function healUnpricedUsd(")); + assert.equal(heal.match(/AND NOT EXISTS \(SELECT 1 FROM bb_pairs pr WHERE pr\.chain_id = l\.chain_id AND pr\.address = l\.quote\)/g)?.length, 2, "trades and fees"); + // only rows whose own hour has a price are picked, each re-tested inside the UPDATE, and the totals move by what was priced + assert.match(heal, /JOIN bb_quote_usd_hourly p ON p\.chain_id = s\.chain_id AND p\.quote = l\.quote AND p\.hour = \$\{db\.unsafe\(hourSql\("s\.block_time"\)\)\}/); + assert.match(heal, /UPDATE bb_launch_swaps s SET usd = t\.v FROM todo t\s+WHERE s\.chain_id = t\.chain_id AND s\.tx_hash = t\.tx_hash AND s\.log_index = t\.log_index AND s\.usd IS NULL/); + assert.match(heal, /UPDATE bb_launches l SET volume_usd = l\.volume_usd \+ x\.v\s+FROM \(SELECT chain_id, token, sum\(usd\) AS v FROM priced GROUP BY chain_id, token\) x/); + assert.match(heal, /f\.kind = 'collected' OR \(f\.kind = 'burned' AND f\.currency = l\.quote\)/); }); diff --git a/app/src/lib/launchpad/usd-history.ts b/app/src/lib/launchpad/usd-history.ts index 9598c99c..cc08acb4 100644 --- a/app/src/lib/launchpad/usd-history.ts +++ b/app/src/lib/launchpad/usd-history.ts @@ -180,7 +180,7 @@ export function buildBackfillSql(opts: { const out: string[] = []; out.push(`-- Trade-time USD backfill, generated by scripts/usd-backfill.ts at ${opts.generatedAt}. MODE: ${opts.mode.toUpperCase()}.`); out.push(`-- Hours ${new Date(hourStartMs(opts.from)).toISOString()} .. ${new Date(hourStartMs(opts.to)).toISOString()} (UTC). Idempotent: prices only rows that have no USD value yet,`); - out.push(`-- and never overwrites an hour the indexer captured live. ${opts.mode === "dry-run" ? "Dry run: everything is ROLLED BACK at the end; only the report is real." : "Apply: prices are written and every launch's USD totals are recomputed."}`); + out.push(`-- and never overwrites an hour the indexer captured live. ${opts.mode === "dry-run" ? "Dry run: everything is ROLLED BACK at the end; only the report is real." : "Apply: ONE transaction that clears community-pair values, prices what has no value yet and recomputes every launch's USD totals."}`); out.push(`\\set ON_ERROR_STOP on`); out.push(`BEGIN;`); out.push(`CREATE TEMP TABLE usd_quote_map (chain_id integer, quote text, series text, decimals integer) ON COMMIT DROP;`); @@ -202,11 +202,15 @@ export function buildBackfillSql(opts: { out.push(` SELECT m.chain_id, m.quote, s.hour, s.usd, m.decimals, s.source FROM usd_series s JOIN usd_quote_map m ON m.series = s.series`); out.push(` ON CONFLICT (chain_id, quote, hour) DO NOTHING;`); const hour = (col: string) => hourSql(col); + // community pairs are never stored (usdLedger.capturable): the report counts them as unpriced, and apply clears any value + // an earlier version captured for them, so they count at the pair's price now (which follows its listing status) + const isPair = (alias: string) => `EXISTS (SELECT 1 FROM bb_pairs pr WHERE pr.chain_id = ${alias}.chain_id AND pr.address = ${alias}.quote)`; out.push(`-- REPORT: every trade at today's price (the old method) vs at its own hour's price (the new one), per chain and quote`); out.push(`WITH latest AS ( SELECT DISTINCT ON (chain_id, quote) chain_id, quote, usd, decimals FROM bb_quote_usd_hourly ORDER BY chain_id, quote, hour DESC ), sw AS ( - SELECT s.chain_id, l.quote, abs(s.amount0) AS amt, COALESCE(s.usd, abs(s.amount0) / power(10::numeric, p.decimals) * p.usd) AS usd_trade_time + SELECT s.chain_id, l.quote, abs(s.amount0) AS amt, + CASE WHEN ${isPair("l")} THEN NULL ELSE COALESCE(s.usd, abs(s.amount0) / power(10::numeric, p.decimals) * p.usd) END AS usd_trade_time FROM bb_launch_swaps s JOIN bb_launches l ON l.chain_id = s.chain_id AND l.token = s.token LEFT JOIN bb_quote_usd_hourly p ON p.chain_id = s.chain_id AND p.quote = l.quote AND p.hour = ${hour("s.block_time")} @@ -223,7 +227,7 @@ SELECT sw.chain_id, sw.quote, count(*) AS trades, SELECT DISTINCT ON (chain_id, quote) chain_id, quote, usd, decimals FROM bb_quote_usd_hourly ORDER BY chain_id, quote, hour DESC ), sw AS ( SELECT abs(s.amount0) / power(10::numeric, lt.decimals) * lt.usd AS usd_latest, - COALESCE(s.usd, abs(s.amount0) / power(10::numeric, p.decimals) * p.usd) AS usd_trade_time + CASE WHEN ${isPair("l")} THEN NULL ELSE COALESCE(s.usd, abs(s.amount0) / power(10::numeric, p.decimals) * p.usd) END AS usd_trade_time FROM bb_launch_swaps s JOIN bb_launches l ON l.chain_id = s.chain_id AND l.token = s.token LEFT JOIN latest lt ON lt.chain_id = s.chain_id AND lt.quote = l.quote @@ -233,7 +237,9 @@ SELECT 'ALL-TIME VOLUME' AS total, round(sum(usd_latest)::numeric) AS usd_at_lat count(*) FILTER (WHERE usd_trade_time IS NULL) AS trades_without_price, count(*) AS trades FROM sw;`); out.push(`WITH fe AS ( - SELECT f.kind, COALESCE(f.usd, CASE WHEN f.kind = 'collected' THEN f.quote_amount ELSE f.amount END / power(10::numeric, p.decimals) * p.usd) AS usd_trade_time + SELECT f.kind, + CASE WHEN ${isPair("l")} THEN NULL + ELSE COALESCE(f.usd, CASE WHEN f.kind = 'collected' THEN f.quote_amount ELSE f.amount END / power(10::numeric, p.decimals) * p.usd) END AS usd_trade_time FROM bb_launch_fee_events f JOIN bb_launches l ON l.chain_id = f.chain_id AND l.token = f.token LEFT JOIN bb_quote_usd_hourly p ON p.chain_id = f.chain_id AND p.quote = l.quote AND p.hour = ${hour("f.block_time")} @@ -246,6 +252,19 @@ SELECT 'FEES (quote side)' AS total, kind, round(sum(usd_trade_time)::numeric) A out.push(`ROLLBACK; -- dry run: nothing was written`); return out.join("\n") + "\n"; } + // ONE transaction from here: clearing pair values, pricing rows and recomputing the totals commit together, so a reader + // never sees rows that are neither unpriced nor in the stored sums (and a failure leaves everything as it was). + out.push(`-- from here, under the same locks as scripts/rebuild-launch-totals.sql: the indexer's ingestion transactions (INSERT event +`); + out.push(`-- UPDATE totals) and its heal pass wait until this commits, then apply on top. Nothing is lost or counted twice.`); + out.push(`LOCK TABLE bb_launch_swaps IN SHARE MODE;`); + out.push(`LOCK TABLE bb_launch_fee_events IN SHARE MODE;`); + out.push(`LOCK TABLE bb_launches IN EXCLUSIVE MODE;`); + out.push(`-- community pairs: no stored value and no ledger rows (an earlier version captured them)`); + out.push(`UPDATE bb_launch_swaps s SET usd = NULL FROM bb_launches l + WHERE s.usd IS NOT NULL AND l.chain_id = s.chain_id AND l.token = s.token AND ${isPair("l")};`); + out.push(`UPDATE bb_launch_fee_events f SET usd = NULL FROM bb_launches l + WHERE f.usd IS NOT NULL AND l.chain_id = f.chain_id AND l.token = f.token AND ${isPair("l")};`); + out.push(`DELETE FROM bb_quote_usd_hourly h WHERE ${isPair("h")};`); out.push(`-- price every trade and fee that has no USD value yet, at its own hour`); out.push(`UPDATE bb_launch_swaps s SET usd = abs(s.amount0) / power(10::numeric, p.decimals) * p.usd FROM bb_launches l, bb_quote_usd_hourly p @@ -259,13 +278,7 @@ SELECT 'FEES (quote side)' AS total, kind, round(sum(usd_trade_time)::numeric) A FROM bb_launches l, bb_quote_usd_hourly p WHERE f.usd IS NULL AND f.kind = 'burned' AND f.currency = l.quote AND l.chain_id = f.chain_id AND l.token = f.token AND p.chain_id = f.chain_id AND p.quote = l.quote AND p.hour = ${hour("f.block_time")};`); - out.push(`COMMIT;`); - out.push(`-- every launch's USD totals, recomputed from the priced rows. Same locking as scripts/rebuild-launch-totals.sql: the indexer's`); - out.push(`-- ingestion transactions (INSERT event + UPDATE totals) wait the few seconds this takes, then apply on top. Nothing is lost.`); - out.push(`BEGIN;`); - out.push(`LOCK TABLE bb_launch_swaps IN SHARE MODE;`); - out.push(`LOCK TABLE bb_launch_fee_events IN SHARE MODE;`); - out.push(`LOCK TABLE bb_launches IN EXCLUSIVE MODE;`); + out.push(`-- every launch's USD totals, recomputed from the priced rows`); out.push(`UPDATE bb_launches l SET volume_usd = COALESCE((SELECT sum(s.usd) FROM bb_launch_swaps s WHERE s.chain_id = l.chain_id AND s.token = l.token), 0), fees_collected_usd = COALESCE((SELECT sum(f.usd) FROM bb_launch_fee_events f WHERE f.chain_id = l.chain_id AND f.token = l.token AND f.kind = 'collected'), 0), diff --git a/app/src/lib/launchpad/usdLedger.ts b/app/src/lib/launchpad/usdLedger.ts index 67241d59..92434d08 100644 --- a/app/src/lib/launchpad/usdLedger.ts +++ b/app/src/lib/launchpad/usdLedger.ts @@ -3,7 +3,7 @@ import { chainIdOf, type ChainKey } from "@/lib/chainPublic"; import { errMessage, type Db } from "@/lib/db"; import { ethUsd } from "./ethPrice"; import { quoteUsdNow } from "./queries"; -import { captureDue, hourIso } from "./usd-history"; +import { captureDue, hourIso, hourSql } from "./usd-history"; /** * Live capture for trade-time USD (usd-history.ts states the rule). Before the indexer applies a range, it writes the @@ -11,14 +11,24 @@ import { captureDue, hourIso } from "./usd-history"; * 'live'), so a trade indexed seconds after it happened is priced at that spot. The row keeps updating until the hour * ends; after that it is frozen, and it is never replaced by a historical close (the backfill fills only hours that have * no row). Prices come from the same sources the lists use (queries.quoteUsdNow). A quote with no price right now is not - * written: its trades keep usd NULL until scripts/usd-backfill.ts fills that hour. Display only, like every USD figure. + * written: its trades keep usd NULL until the heal pass below (or scripts/usd-backfill.ts) prices them. Display only. + * + * Community pairs are NEVER captured (capturable): a pair priced from an outside pool can be pushed for a minute (season + * points exclude those for that reason, queries.quotePricer), and a price frozen into a trade would stay in the totals for + * good; and a suspended or delisted pair must show no USD at all, which a stored value would defeat. Their trades keep usd + * NULL and count at the pair's price now, which follows its listing status (trade-time-usd.ts). */ +/** Which quotes the live capture stores a price for: every priced quote except community pairs (see above). */ +export function capturable(key: string): boolean { + return key !== "pair" && key !== "other"; +} export const LIVE_CAPTURE_EVERY_MS = 60_000; const lastCapture = new Map(); -/** Test-only: forget when each chain was last captured. */ +/** Test-only: forget when each chain was last captured and healed. */ export function resetLiveCapture(): void { lastCapture.clear(); + lastHeal.clear(); } /** @@ -35,7 +45,7 @@ export async function captureLiveUsd(db: Db, chain: ChainKey, quotes: Iterable { const p = price(chain, quote); - return p.usd !== null && Number.isFinite(p.usd) && p.usd > 0 && p.decimalsKnown ? [{ quote, usd: p.usd, decimals: p.decimals }] : []; + return capturable(p.key) && p.usd !== null && Number.isFinite(p.usd) && p.usd > 0 && p.decimalsKnown ? [{ quote, usd: p.usd, decimals: p.decimals }] : []; }); if (rows.length === 0) return 0; await db` @@ -53,3 +63,74 @@ export async function captureLiveUsd(db: Db, chain: ChainKey, quotes: Iterable(); + +export async function healUnpricedUsd(db: Db, chain: ChainKey, now = Date.now()): Promise<{ swaps: number; fees: number }> { + const last = lastHeal.get(chain); + if (last !== undefined && now - last < HEAL_EVERY_MS) return { swaps: 0, fees: 0 }; + lastHeal.set(chain, now); + const cid = chainIdOf(chain); + try { + const swaps = await db<{ n: number }[]>` + WITH todo AS ( + SELECT s.chain_id, s.tx_hash, s.log_index, abs(s.amount0) / power(10::numeric, p.decimals) * p.usd AS v + FROM bb_launch_swaps s + JOIN bb_launches l ON l.chain_id = s.chain_id AND l.token = s.token + JOIN bb_quote_usd_hourly p ON p.chain_id = s.chain_id AND p.quote = l.quote AND p.hour = ${db.unsafe(hourSql("s.block_time"))} + WHERE s.chain_id = ${cid} AND s.usd IS NULL AND s.block_time > now() - ${HEAL_WINDOW}::interval + AND NOT EXISTS (SELECT 1 FROM bb_pairs pr WHERE pr.chain_id = l.chain_id AND pr.address = l.quote) + LIMIT ${HEAL_BATCH} + ), priced AS ( + UPDATE bb_launch_swaps s SET usd = t.v FROM todo t + WHERE s.chain_id = t.chain_id AND s.tx_hash = t.tx_hash AND s.log_index = t.log_index AND s.usd IS NULL + RETURNING s.chain_id, s.token, s.usd + ), totals AS ( + UPDATE bb_launches l SET volume_usd = l.volume_usd + x.v + FROM (SELECT chain_id, token, sum(usd) AS v FROM priced GROUP BY chain_id, token) x + WHERE l.chain_id = x.chain_id AND l.token = x.token + RETURNING 1 + ) + SELECT (SELECT count(*) FROM priced)::int AS n`; + const fees = await db<{ n: number }[]>` + WITH todo AS ( + SELECT f.chain_id, f.tx_hash, f.log_index, + CASE WHEN f.kind = 'collected' THEN f.quote_amount ELSE f.amount END / power(10::numeric, p.decimals) * p.usd AS v + FROM bb_launch_fee_events f + JOIN bb_launches l ON l.chain_id = f.chain_id AND l.token = f.token + JOIN bb_quote_usd_hourly p ON p.chain_id = f.chain_id AND p.quote = l.quote AND p.hour = ${db.unsafe(hourSql("f.block_time"))} + WHERE f.chain_id = ${cid} AND f.usd IS NULL AND f.block_time > now() - ${HEAL_WINDOW}::interval + AND (f.kind = 'collected' OR (f.kind = 'burned' AND f.currency = l.quote)) + AND NOT EXISTS (SELECT 1 FROM bb_pairs pr WHERE pr.chain_id = l.chain_id AND pr.address = l.quote) + LIMIT ${HEAL_BATCH} + ), priced AS ( + UPDATE bb_launch_fee_events f SET usd = t.v FROM todo t + WHERE f.chain_id = t.chain_id AND f.tx_hash = t.tx_hash AND f.log_index = t.log_index AND f.usd IS NULL + RETURNING f.chain_id, f.token, f.kind, f.usd + ), totals AS ( + UPDATE bb_launches l SET fees_collected_usd = l.fees_collected_usd + x.c, fees_burned_usd = l.fees_burned_usd + x.b + FROM (SELECT chain_id, token, COALESCE(sum(usd) FILTER (WHERE kind = 'collected'), 0) AS c, + COALESCE(sum(usd) FILTER (WHERE kind = 'burned'), 0) AS b + FROM priced GROUP BY chain_id, token) x + WHERE l.chain_id = x.chain_id AND l.token = x.token + RETURNING 1 + ) + SELECT (SELECT count(*) FROM priced)::int AS n`; + return { swaps: swaps[0]?.n ?? 0, fees: fees[0]?.n ?? 0 }; + } catch (err) { + if (last === undefined) lastHeal.delete(chain); + else lastHeal.set(chain, last); + console.warn(`[usd] heal pass failed on ${chain}: ${errMessage(err)}`); + return { swaps: 0, fees: 0 }; + } +} diff --git a/app/src/lib/launchpad/volume-window.test.ts b/app/src/lib/launchpad/volume-window.test.ts index 3e0edf5d..adb452a1 100644 --- a/app/src/lib/launchpad/volume-window.test.ts +++ b/app/src/lib/launchpad/volume-window.test.ts @@ -2,7 +2,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { SELECT, VOLUME_COLUMN_SQL, VOLUME_WINDOWS } from "./queries.ts"; -import { UNPRICED_SWAPS_SQL } from "./trade-time-usd.ts"; +import { UNPRICED_LATERAL_SQL } from "./trade-time-usd.ts"; /** * The Volume sort orders by a column per window. The window volumes live on the lateral subquery aliased `w` @@ -11,8 +11,8 @@ import { UNPRICED_SWAPS_SQL } from "./trade-time-usd.ts"; const lateral = SELECT.slice(SELECT.indexOf("LEFT JOIN LATERAL ("), SELECT.lastIndexOf(") w ON")); const wColumns = new Set([...lateral.matchAll(/\bAS (\w+)/g)].map((m) => m[1])); // aliases visible to the ORDER BY (the lateral's own inner alias is not): "FROM bb_launches l", "JOIN bb_launch_meta m", "LATERAL (...) w" -// "LEFT JOIN () u" is the trade-time USD join (trade-time-usd.ts); its inner FROM is not an alias of the outer query -const definedAliases = new Set([...SELECT.replace(lateral, "LATERAL (...").replace(UNPRICED_SWAPS_SQL, "...").matchAll(/\b(?:FROM|JOIN) bb_\w+ (\w+)|\) (\w+) ON/g)].map((m) => m[1] ?? m[2])); +// "LEFT JOIN LATERAL () u" is the trade-time USD join (trade-time-usd.ts); its inner FROM is not an alias of the outer query +const definedAliases = new Set([...SELECT.replace(lateral, "LATERAL (...").replace(UNPRICED_LATERAL_SQL, "...").matchAll(/\b(?:FROM|JOIN) bb_\w+ (\w+)|\) (\w+) ON/g)].map((m) => m[1] ?? m[2])); test("the lateral subquery is still aliased w and carries the window volumes", () => { assert.ok(SELECT.includes(") w ON true"));