diff --git a/apps/pwa/src/moshpit.mjs b/apps/pwa/src/moshpit.mjs index c4a10bc..d562c46 100644 --- a/apps/pwa/src/moshpit.mjs +++ b/apps/pwa/src/moshpit.mjs @@ -39,8 +39,28 @@ export async function listTlds(limit = 200) { return all(`SELECT ${COLS} FROM moshpit_tlds ORDER BY created_at DESC LIMIT ?`, [limit]); } -export async function listTldsForUser(userId) { - return all(`SELECT ${COLS} FROM moshpit_tlds WHERE user_id = ? ORDER BY created_at DESC`, [userId]); +/** + * The endings one account holds. + * + * `limit`/`offset` page it. They are optional because the JSON API hands the + * whole list back and a list of strings costs nothing -- it is /pit that cannot + * afford it, because every ending it draws brings a form per name with it. + * + * Ordered by `created_at DESC, tld` rather than `created_at DESC` alone. A bulk + * claim writes one timestamp across every ending in it, so `created_at` is not + * a total order, and a page boundary landing inside a tie would show the same + * ending twice on one page and skip another entirely. + */ +export async function listTldsForUser(userId, { limit = null, offset = 0 } = {}) { + const page = limit === null ? "" : ` LIMIT ? OFFSET ?`; + const args = limit === null ? [userId] : [userId, limit, offset]; + return all(`SELECT ${COLS} FROM moshpit_tlds WHERE user_id = ? ORDER BY created_at DESC, tld${page}`, args); +} + +/** How many endings the account holds -- for the pager, which needs the total. */ +export async function countTldsForUser(userId) { + const row = await get(`SELECT COUNT(*) AS n FROM moshpit_tlds WHERE user_id = ?`, [userId]); + return Number(row?.n ?? 0); } /** @@ -205,6 +225,17 @@ export async function listNames(tld, limit = 500) { return all(`SELECT ${NAME_COLS} FROM moshpit_names WHERE tld = ? ORDER BY label LIMIT ?`, [tld, limit]); } +/** + * How many names live under an ending. + * + * /pit draws a handful of them per ending and has to say how many it is not + * drawing -- "12 shown" with no total reads as "you have 12 names". + */ +export async function countNames(tld) { + const row = await get(`SELECT COUNT(*) AS n FROM moshpit_names WHERE tld = ?`, [tld]); + return Number(row?.n ?? 0); +} + export async function listNamesForUser(userId) { return all(`SELECT ${NAME_COLS} FROM moshpit_names WHERE user_id = ? ORDER BY tld, label`, [userId]); } @@ -317,13 +348,28 @@ export async function setTldPrice({ tld: tldInput, userId, priceUsd }) { return { ok: true, tld, priceUsd: price }; } -/** TLDs somebody else holds. `forSale` narrows to the ones actually buyable. */ -export async function listTldsNotOwnedBy(userId, { forSale = false, limit = 200 } = {}) { +/** + * TLDs somebody else holds. `forSale` narrows to the ones actually buyable. + * + * `tld` breaks the tie for the same reason it does in listTldsForUser: a bulk + * claim shares one timestamp, and paging through a partial order loses rows. + */ +export async function listTldsNotOwnedBy(userId, { forSale = false, limit = 200, offset = 0 } = {}) { const sql = `SELECT tld, user_id, owner_email, alias_of, price_usd, created_at FROM moshpit_tlds WHERE user_id IS NOT ?${forSale ? " AND price_usd IS NOT NULL" : ""} - ORDER BY price_usd IS NULL, created_at DESC LIMIT ?`; - return all(sql, [userId ?? "", limit]); + ORDER BY price_usd IS NULL, created_at DESC, tld LIMIT ? OFFSET ?`; + return all(sql, [userId ?? "", limit, offset]); +} + +/** How many endings somebody else holds -- the Theirs pager needs the total. */ +export async function countTldsNotOwnedBy(userId, { forSale = false } = {}) { + const row = await get( + `SELECT COUNT(*) AS n FROM moshpit_tlds + WHERE user_id IS NOT ?${forSale ? " AND price_usd IS NOT NULL" : ""}`, + [userId ?? ""], + ); + return Number(row?.n ?? 0); } export async function getTldWithPrice(tld) { diff --git a/apps/pwa/src/routes/moshpit.mjs b/apps/pwa/src/routes/moshpit.mjs index 34461e9..c142554 100644 --- a/apps/pwa/src/routes/moshpit.mjs +++ b/apps/pwa/src/routes/moshpit.mjs @@ -25,6 +25,9 @@ import { addPin, clearAlias, clearExempt, + countNames, + countTldsForUser, + countTldsNotOwnedBy, DEFAULT_TLD_PRICE_USD, getName, getTld, @@ -774,6 +777,8 @@ const PIT_CSS = ` font-size:.8rem;line-height:1.55;resize:vertical;min-height:9em} .pit-bulk textarea:focus{outline:none;border-color:var(--acid)} .pit-tabs{display:flex;gap:4px;margin:22px 0 26px;border-bottom:1px solid var(--line)} +.pit-pager{display:flex;gap:12px;align-items:center;justify-content:space-between;flex-wrap:wrap;margin:22px 0 4px;font-size:.72rem} +.pit-pager .btn[aria-disabled]{opacity:.4;pointer-events:none} .pit-tab{font-family:var(--mono);font-size:.76rem;letter-spacing:.12em;text-transform:uppercase;color:var(--dim); padding:11px 15px;border-bottom:2px solid transparent;margin-bottom:-1px} .pit-tab:hover{color:var(--text)} @@ -814,12 +819,67 @@ const pitTabs = (active, counts = null) => ` const forSale = (t) => t.price_usd !== null && t.price_usd !== undefined; +/** + * How much of the namespace one page may draw. + * + * /pit ships no script at all, and it still locked browsers up: the page grew + * as endings x names-under-them, and neither end was bounded. Every name is a + * form with a CSRF field, two inputs and two buttons, so an account holding 50 + * endings with 100 names each rendered 3 MiB of HTML and 36k elements. Nothing + * has to be slow for that to jam -- it is the DOM, and the sticky blurred app + * bar repainting over it on every scroll frame. + * + * So the page shows a window and says what it is not showing. `?tld=` opens one + * ending in full, which is also where the "show all N" links go -- and where + * TronBrowser's `mosh.` already pointed, on a page that until now ignored + * the parameter and drew everything anyway. + */ +const TLDS_PER_PAGE = 20; +const NAMES_PER_TLD = 10; +const NAMES_FOCUSED = 250; + +/** `?page=` as a 1-based page number; anything unreadable is page 1. */ +const pageParam = (value) => { + const n = Number.parseInt(value, 10); + return Number.isFinite(n) && n > 1 ? n : 1; +}; + +/** Prev/next for a window into `total` rows, or nothing when it all fits. */ +const pager = ({ page, total, perPage, href }) => { + const pages = Math.max(1, Math.ceil(total / perPage)); + if (pages <= 1) return ""; + const link = (n, label) => `${label}`; + return ``; +}; + moshpitRouter.get("/pit", async (req, res) => { - const [theirs, mine, bal] = await Promise.all([ - listTldsNotOwnedBy(req.user?.id ?? null, { limit: 100 }), - req.user ? listTldsForUser(req.user.id) : [], + // An unknown ?tab= falls back to Yours rather than rendering an empty page. + const tab = req.query.tab === "theirs" ? "theirs" : "yours"; + const pageNo = pageParam(req.query.page); + + // `?tld=` opens a single ending in full. Only meaningful for one you hold -- + // Theirs is one row per ending and has nothing to expand. + const wanted = normalizeTld(req.query.tld) || null; + const focused = tab === "yours" && req.user && wanted + ? await getTld(wanted).then((t) => (t && t.user_id === req.user.id ? t : null)) + : null; + + const [theirs, theirsTotal, mine, mineTotal, bal] = await Promise.all([ + tab === "theirs" + ? listTldsNotOwnedBy(req.user?.id ?? null, { limit: TLDS_PER_PAGE, offset: (pageNo - 1) * TLDS_PER_PAGE }) + : [], + countTldsNotOwnedBy(req.user?.id ?? null), + req.user && !focused + ? listTldsForUser(req.user.id, { limit: TLDS_PER_PAGE, offset: (pageNo - 1) * TLDS_PER_PAGE }) + : [], + req.user ? countTldsForUser(req.user.id) : 0, req.user ? balance(req.user.id) : 0, ]); + const shown = focused ? [focused] : mine; // `?name=mosh.whatever` — somebody typed a Moshpit name and ended up here // instead of at a site. Work out what they can actually do about it. @@ -839,18 +899,22 @@ moshpitRouter.get("/pit", async (req, res) => { }); } - // An unknown ?tab= falls back to Yours rather than rendering an empty page. - const tab = req.query.tab === "theirs" ? "theirs" : "yours"; - const forSaleCount = theirs.filter(forSale).length; + const forSaleCount = await countTldsNotOwnedBy(req.user?.id ?? null, { forSale: true }); - // Per-TLD detail is only needed by the panel actually on screen, and only - // Yours has any: Theirs is one row per ending. + // Per-TLD detail is only needed by the endings actually on screen, and only + // Yours has any: Theirs is one row per ending. That is the whole fix -- this + // used to run one listNames per ending the account held, however many that + // was, and then render every row it got back. const exemptions = new Map(); const names = new Map(); + const nameTotals = new Map(); if (tab === "yours") { // Exemptions are only meaningful for a TLD that points somewhere. - await Promise.all(mine.filter((t) => t.alias_of).map(async (t) => exemptions.set(t.tld, await listExempt(t.tld)))); - await Promise.all(mine.map(async (t) => names.set(t.tld, await listNames(t.tld)))); + await Promise.all(shown.filter((t) => t.alias_of).map(async (t) => exemptions.set(t.tld, await listExempt(t.tld)))); + await Promise.all(shown.map(async (t) => { + names.set(t.tld, await listNames(t.tld, focused ? NAMES_FOCUSED : NAMES_PER_TLD)); + nameTotals.set(t.tld, await countNames(t.tld)); + })); } const msg = req.query.err ? `

${esc(req.query.err)}

` @@ -859,12 +923,13 @@ moshpitRouter.get("/pit", async (req, res) => { const mineHtml = !req.user ? `

Sign in with your moshcode account to claim one — the same login the CLI uses.

Sign in →

` - : mine.length - ? mine.map((t) => ` + : shown.length + ? shown.map((t) => `

.${esc(t.tld)}

${t.alias_of ? `points at .${esc(t.alias_of)}` : "stands on its own"} + · ${nameTotals.get(t.tld) ?? 0} name${(nameTotals.get(t.tld) ?? 0) === 1 ? "" : "s"}
${(names.get(t.tld) || []).length @@ -878,6 +943,12 @@ moshpitRouter.get("/pit", async (req, res) => { `).join("") : `

no names under .${esc(t.tld)} yet

`} + ${(nameTotals.get(t.tld) ?? 0) > (names.get(t.tld) || []).length ? ` +

+ ${(names.get(t.tld) || []).length} of ${nameTotals.get(t.tld)} shown${focused + ? ` — this ending holds more than the ${NAMES_FOCUSED} a page will draw` + : ` · open .${esc(t.tld)} on its own →`} +

` : ""}
${csrfInput(req)} @@ -955,15 +1026,24 @@ moshpitRouter.get("/pit", async (req, res) => { ${landingCard(req, landing)} ${msg} ${req.user ? claimForm(req) + bulkClaimForm(req) : ""} - ${pitTabs(tab, { yours: mine.length, theirs: theirs.length, forSale: forSaleCount })} + ${pitTabs(tab, { yours: mineTotal, theirs: theirsTotal, forSale: forSaleCount })}
${tab === "yours" ? ` + ${focused ? ` +

+ ← all ${mineTotal} of your endings · showing + .${esc(focused.tld)} on its own. +

` : `

Endings you hold. Names under them are yours to mint for nothing — or put a price on the ending and let anyone buy one. -

+

`} ${mineHtml} + ${focused ? "" : pager({ + page: pageNo, total: mineTotal, perPage: TLDS_PER_PAGE, + href: (n) => `/pit?tab=yours&page=${n}`, + })} ` : `

Endings somebody else holds. Where the operator has set a price you can buy a name under it — @@ -971,6 +1051,10 @@ moshpitRouter.get("/pit", async (req, res) => { through CoinPay; the name lands the moment the payment confirms.

${theirsHtml} + ${pager({ + page: pageNo, total: theirsTotal, perPage: TLDS_PER_PAGE, + href: (n) => `/pit?tab=theirs&page=${n}`, + })} `}
${footer}`, diff --git a/apps/pwa/test/moshpit-pit-page.test.mjs b/apps/pwa/test/moshpit-pit-page.test.mjs new file mode 100644 index 0000000..e8050e7 --- /dev/null +++ b/apps/pwa/test/moshpit-pit-page.test.mjs @@ -0,0 +1,155 @@ +// What /pit is allowed to draw. +// +// The page ships no script and still locked browsers up: it rendered every +// ending the account held, and under each one a form per name, with no bound on +// either. At 50 endings x 100 names that was 3 MiB of HTML and 36k elements — +// enough to jam scrolling on its own, before the sticky blurred app bar +// repainted over it every frame. +// +// So these tests are about size, not correctness of content: a page that grows +// with the size of the namespace is the bug, and it comes back the moment +// somebody renders a list without a limit. +// +// Same harness as sessions.test.mjs: the real router against a throwaway libsql +// file, skipped cleanly when the PWA deps are not installed. +import assert from "node:assert/strict"; +import fs from "node:fs"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; +import test from "node:test"; + +const require = createRequire(import.meta.url); +let deps = null; +try { + deps = { express: require("express") }; +} catch { + deps = null; +} + +const workdir = mkdtempSync(path.join(tmpdir(), "moshcode-pit-page-test-")); +process.env.DATABASE_URL = `file:${path.join(workdir, "test.db")}`; +process.env.SESSION_SECRET = "test-secret"; + +const TLDS = 50; +const NAMES = 100; + +async function boot() { + const { migrate } = await import("../src/migrate.mjs"); + await migrate(); + const { run, db } = await import("../src/db.mjs"); + const { moshpitRouter } = await import("../src/routes/moshpit.mjs"); + + await run(`INSERT OR REPLACE INTO users (id, email, display_name, created_at) VALUES ('u1','a@b.c','one',1)`); + await run(`INSERT OR REPLACE INTO users (id, email, display_name, created_at) VALUES ('u2','x@y.z','two',1)`); + + // One timestamp across every ending, on purpose: that is what a bulk claim + // writes, so `ORDER BY created_at DESC` alone is not a total order and paging + // through it can repeat one row and drop another. + const at = 1_700_000_000_000; + for (let i = 0; i < TLDS; i++) { + const tld = `tld${String(i).padStart(3, "0")}`; + await run(`INSERT INTO moshpit_tlds (tld,user_id,owner_email,alias_of,created_at) VALUES (?,?,?,?,?)`, + [tld, "u1", "a@b.c", null, at]); + for (let n = 0; n < NAMES; n++) { + await run(`INSERT INTO moshpit_names (tld,label,user_id,target,created_at) VALUES (?,?,?,?,?)`, + [tld, `name${String(n).padStart(3, "0")}`, "u1", null, at]); + } + } + // Somebody else's ending, so Theirs has a row and a count of its own. + await run(`INSERT INTO moshpit_tlds (tld,user_id,owner_email,alias_of,price_usd,created_at) VALUES (?,?,?,?,?,?)`, + ["mine", "u2", "x@y.z", null, 3, at]); + + const app = deps.express(); + app.use(deps.express.urlencoded({ extended: false })); + app.use((req, _res, next) => { req.csrfToken = () => "csrf"; req.user = { id: "u1", email: "a@b.c" }; next(); }); + app.use(moshpitRouter); + const server = await new Promise((resolve) => { + const s = app.listen(0, "127.0.0.1", () => resolve(s)); + }); + const base = `http://127.0.0.1:${server.address().port}`; + const get = async (p) => { + const res = await fetch(`${base}${p}`); + return { status: res.status, html: await res.text() }; + }; + return { server, db, get }; +} + +let booted = null; +const app = () => (booted ||= boot()); + +test.after(() => { + if (!booted) return; + booted.then(({ server, db }) => { server.close(); db.close?.(); }) + .finally(() => { try { fs.rmSync(workdir, { recursive: true, force: true }); } catch { /* noop */ } }); +}); + +const skip = { skip: !deps && "apps/pwa deps not installed" }; + +/** Endings drawn as a panel heading on this page. */ +const endings = (html) => [...html.matchAll(/

\.([a-z0-9]+)<\/h3>/g)].map((m) => m[1]); +const elements = (html) => (html.match(/<[a-z]/g) || []).length; + +test("pit: the page does not grow with the size of the namespace", skip, async () => { + const { get } = await app(); + const { status, html } = await get("/pit"); + assert.equal(status, 200); + + // 5000 names exist. The unbounded page rendered all of them; this one draws a + // window. The ceiling is deliberately loose — it is a guard against "no limit + // at all" coming back, not a pixel budget. + assert.ok(elements(html) < 4000, `page drew ${elements(html)} elements`); + assert.ok(html.length < 500 * 1024, `page was ${(html.length / 1024).toFixed(0)} KiB`); + assert.ok(endings(html).length <= 20, `page drew ${endings(html).length} endings`); +}); + +test("pit: what is not drawn is stated rather than silently dropped", skip, async () => { + const { get } = await app(); + const { html } = await get("/pit"); + + // The count is the honest part: a page showing 10 of 100 names with no total + // is indistinguishable from an account that holds 10 names. + assert.match(html, /100 names/, "each ending reports how many names it holds"); + assert.match(html, /10 of 100 shown/, "and how many of them are on screen"); + assert.match(html, /page 1 of 3 · 50 endings/, "the pager states the total"); +}); + +test("pit: paging covers every ending exactly once, even on tied timestamps", skip, async () => { + const { get } = await app(); + const seen = []; + for (const p of [1, 2, 3]) seen.push(...endings((await get(`/pit?page=${p}`)).html)); + + assert.equal(seen.length, TLDS, "every ending appears"); + assert.equal(new Set(seen).size, TLDS, "and none appears twice"); +}); + +test("pit: ?tld= opens one ending with more of it than the list shows", skip, async () => { + const { get } = await app(); + const { status, html } = await get("/pit?tld=tld007"); + assert.equal(status, 200); + + assert.deepEqual(endings(html), ["tld007"], "only the ending asked for"); + // 100 names, all of them, because there is only one ending on the page. + assert.match(html, /name099\.tld007/, "the whole ending is drawn"); + assert.doesNotMatch(html, /10 of 100 shown/, "nothing is being held back here"); +}); + +test("pit: ?tld= is not a way to read an ending somebody else holds", skip, async () => { + const { get } = await app(); + const { html } = await get("/pit?tld=mine"); + + // Falls back to the normal paged view rather than opening u2's ending in the + // panel that carries edit and release buttons. + assert.ok(endings(html).length > 1, "did not focus another account's ending"); + assert.ok(!endings(html).includes("mine"), "and did not draw it among yours"); +}); + +test("pit: an unreadable ?page= is page one, not an empty panel", skip, async () => { + const { get } = await app(); + for (const q of ["?page=0", "?page=-3", "?page=banana", "?page="]) { + const { html } = await get(`/pit${q}`); + assert.equal(endings(html).length, 20, `${q} drew an empty or partial page`); + assert.match(html, /page 1 of 3/, `${q} did not land on page one`); + } +});