From 9d7286b5bd050ce67600100f55dbe90f87557165 Mon Sep 17 00:00:00 2001 From: anthony Date: Fri, 31 Jul 2026 10:34:55 +0000 Subject: [PATCH 1/2] pit: raise the paste ceiling to 1000 and stop on the clock, not a count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A real paste hit the old cap: 195 claimed, "389 past the 200 limit, not attempted" — and nothing said what to do about the 389. Two changes. The ceiling goes 200 -> 1000, but it is no longer the thing that usually stops a paste. Claiming now runs against a 20s budget and stops when the budget is spent. A count cannot know how slow the database is today; the failure it was guarding against was a request dying halfway with no report of what landed, and a clock guards that directly. A fast database gets through hundreds, a slow one stops early, and neither ends as a timed-out request whose result nobody sees. The budget is checked before each write and never before the first, so an already-slow database still claims one rather than reporting a paste that did nothing and looks broken. Whatever is left is now named and actionable: "N not attempted — paste them again to carry on", covering both the over-ceiling and out-of-time cases, which are the same problem from the user's side. Caught while testing: the single-ending shortcut in summarizeBulkClaim did not know about leftovers, so one claim plus four unattempted reported as ".x is yours." and silently lost the four. 6 more tests. 262 across the pwa suite. Co-Authored-By: Claude Opus 5 (1M context) --- apps/pwa/src/lib/moshpit-name.mjs | 24 ++++++++-- apps/pwa/src/moshpit.mjs | 25 +++++++--- apps/pwa/test/moshpit-bulk-claim.test.mjs | 58 +++++++++++++++++++++++ 3 files changed, 96 insertions(+), 11 deletions(-) diff --git a/apps/pwa/src/lib/moshpit-name.mjs b/apps/pwa/src/lib/moshpit-name.mjs index 8cac8ba..f798557 100644 --- a/apps/pwa/src/lib/moshpit-name.mjs +++ b/apps/pwa/src/lib/moshpit-name.mjs @@ -114,13 +114,27 @@ export function resolutionPreference({ registered, mode }) { } /** - * How many endings one paste may claim at a time. + * The most endings one paste may claim. * - * A cap rather than no cap because this runs one INSERT per ending against a - * remote database, and a pasted spreadsheet column is exactly the shape of - * input that turns into ten thousand of them by accident. + * A ceiling rather than no ceiling because this runs one INSERT per ending + * against a remote database, and a pasted spreadsheet column is exactly the + * shape of input that turns into ten thousand of them by accident. + * + * It is not the thing that usually stops a paste, though — BULK_TIME_BUDGET_MS + * is. A count cannot know how slow the database is today, and the failure it + * guards against is a request that dies halfway with no report of what landed. + */ +export const MAX_BULK_TLDS = 1000; + +/** + * How long claiming may run before it stops and reports. + * + * Stopping on the clock rather than on a count adapts to the database: a fast + * one gets through hundreds, a slow one stops early, and neither ends as a + * timed-out request whose result nobody ever sees. Whatever is left is named + * so it can be pasted again. */ -export const MAX_BULK_TLDS = 200; +export const BULK_TIME_BUDGET_MS = 20_000; /** * The most a child name may cost per year. diff --git a/apps/pwa/src/moshpit.mjs b/apps/pwa/src/moshpit.mjs index 13f8789..b3b48b1 100644 --- a/apps/pwa/src/moshpit.mjs +++ b/apps/pwa/src/moshpit.mjs @@ -11,10 +11,10 @@ // checkable rather than trusted. import { get, all, run } from "./db.mjs"; -import { MAX_BULK_TLDS, MAX_CHILD_PRICE_USD, normalizeLabel, normalizeTld, parseMoshpitName, parseTldList, tldRejection } from "./lib/moshpit-name.mjs"; +import { BULK_TIME_BUDGET_MS, MAX_BULK_TLDS, MAX_CHILD_PRICE_USD, normalizeLabel, normalizeTld, parseMoshpitName, parseTldList, tldRejection } from "./lib/moshpit-name.mjs"; export { - RESERVED_TLDS, RESOLVE_MODES, MAX_BULK_TLDS, DEFAULT_TLD_PRICE_USD, MAX_CHILD_PRICE_USD, normalizeLabel, normalizeTld, parseMoshpitName, + RESERVED_TLDS, RESOLVE_MODES, MAX_BULK_TLDS, BULK_TIME_BUDGET_MS, DEFAULT_TLD_PRICE_USD, MAX_CHILD_PRICE_USD, normalizeLabel, normalizeTld, parseMoshpitName, parseTldList, tldRejection, normalizeMode, resolutionPreference, } from "./lib/moshpit-name.mjs"; @@ -578,8 +578,11 @@ export async function removePin({ tld: tldInput, label: labelInput, pin, userId */ export async function registerTlds({ input, userId, ownerEmail = null, limit = MAX_BULK_TLDS, priceUsd = null, aliasOf = null, + budgetMs = BULK_TIME_BUDGET_MS, now = Date.now, }) { const { entries, skipped } = parseTldList(input, limit); + const deadline = now() + budgetMs; + const remaining = []; const claimed = []; const mine = []; @@ -587,7 +590,13 @@ export async function registerTlds({ const rejected = []; const settingsFailed = []; - for (const entry of entries) { + for (const [index, entry] of entries.entries()) { + // Checked before the write, not after: stopping with a claim half-made is + // the one outcome worse than stopping early. + if (index > 0 && now() >= deadline) { + remaining.push(...entries.slice(index).map((e) => e.tld)); + break; + } const tld = entry.tld; const result = await registerTld({ tld, userId, ownerEmail }); if (result.ok) { @@ -619,7 +628,7 @@ export async function registerTlds({ rejected.push({ tld, error: result.error }); } - return { claimed, mine, taken, rejected, settingsFailed, skipped, attempted: entries.length }; + return { claimed, mine, taken, rejected, settingsFailed, skipped, remaining, attempted: entries.length }; } /** @@ -654,7 +663,8 @@ export function summarizeBulkClaim(result, limit = MAX_BULK_TLDS) { // batch report looks like, and the commonest path through this page is one // ending typed into one box. const onlyClaimed = result.claimed.length === 1 && !result.mine.length && !result.taken.length - && !result.rejected.length && !result.settingsFailed?.length && !result.skipped; + && !result.rejected.length && !result.settingsFailed?.length && !result.skipped + && !result.remaining?.length; if (onlyClaimed) return `.${result.claimed[0]} is yours.`; const parts = []; @@ -671,7 +681,10 @@ export function summarizeBulkClaim(result, limit = MAX_BULK_TLDS) { const first = result.settingsFailed[0]; parts.push(`${result.settingsFailed.length} claimed but not configured (.${first.tld} — ${first.error})`); } - if (result.skipped) parts.push(`${result.skipped} past the ${limit} limit, not attempted`); + // The leftovers are the actionable part, so they say what to do rather than + // just how many there were. + const left = (result.remaining?.length || 0) + (result.skipped || 0); + if (left) parts.push(`${left} not attempted — paste them again to carry on`); return parts.length ? parts.join(". ") + "." : "nothing to claim — paste one ending per line."; } diff --git a/apps/pwa/test/moshpit-bulk-claim.test.mjs b/apps/pwa/test/moshpit-bulk-claim.test.mjs index cd0e8b0..b210f9d 100644 --- a/apps/pwa/test/moshpit-bulk-claim.test.mjs +++ b/apps/pwa/test/moshpit-bulk-claim.test.mjs @@ -340,3 +340,61 @@ test("per-line settings beat the form", { skip: installed ? false : "pwa depende assert.equal(row.alias_of, hub); }); }); + +test("a paste bigger than one request can finish", { skip: installed ? false : "pwa dependencies not installed" }, async (t) => { + const { migrate } = await import("../src/migrate.mjs"); + await migrate(); + const { run } = await import("../src/db.mjs"); + await run(`INSERT OR IGNORE INTO users (id,email,created_at) VALUES (?,?,?)`, [ALICE, "alice@example.com", Date.now()]); + const m = await import("../src/moshpit.mjs"); + const { BULK_TIME_BUDGET_MS, MAX_BULK_TLDS } = await import("../src/lib/moshpit-name.mjs"); + const uniq = () => `t${randomBytes(4).toString("hex")}`; + + await t.test("the ceiling is 1000", () => { + assert.equal(MAX_BULK_TLDS, 1000); + assert.ok(BULK_TIME_BUDGET_MS > 0); + }); + + await t.test("running out of time names what is left instead of dropping it", async () => { + const names = Array.from({ length: 5 }, uniq); + // A clock that jumps past the budget after the first claim. + let calls = 0; + const result = await m.registerTlds({ + input: names.join("\n"), userId: ALICE, budgetMs: 1000, + now: () => (calls++ === 0 ? 0 : 99_999), + }); + + assert.equal(result.claimed.length, 1, "the first one lands"); + assert.deepEqual(result.remaining, names.slice(1), "the rest are named, not lost"); + assert.match(m.summarizeBulkClaim(result), /4 not attempted — paste them again/); + }); + + await t.test("the budget is never checked before the first claim", async () => { + // An already-expired clock must still do one, or a slow database means a + // paste that claims nothing at all and looks broken. + const one = uniq(); + const result = await m.registerTlds({ + input: one, userId: ALICE, budgetMs: 0, now: () => 99_999, + }); + assert.deepEqual(result.claimed, [one]); + assert.deepEqual(result.remaining, []); + }); + + await t.test("a paste that fits reports nothing left over", async () => { + const names = Array.from({ length: 3 }, uniq); + const result = await m.registerTlds({ input: names.join("\n"), userId: ALICE }); + + assert.equal(result.claimed.length, 3); + assert.deepEqual(result.remaining, []); + assert.doesNotMatch(m.summarizeBulkClaim(result), /not attempted/); + }); + + await t.test("over the ceiling still counts as left over, not dropped", async () => { + const names = Array.from({ length: 4 }, uniq); + const result = await m.registerTlds({ input: names.join("\n"), userId: ALICE, limit: 2 }); + + assert.equal(result.claimed.length, 2); + assert.equal(result.skipped, 2); + assert.match(m.summarizeBulkClaim(result), /2 not attempted — paste them again/); + }); +}); From 5e6f5a131608223c92016cabbcddb4640f3160f6 Mon Sep 17 00:00:00 2001 From: anthony Date: Fri, 31 Jul 2026 10:39:01 +0000 Subject: [PATCH 2/2] pit: say "1k", not "1000" The hint interpolates the ceiling, so raising it to 1000 made the line read "Up to 1000 at a time." A ceiling is a rough promise and should read like one. Only exact thousands are shortened. 1500 stays 1500, because "1.5k" reads as an approximation of a number that is exact, and the whole point of the line is telling someone where the limit actually is. Co-Authored-By: Claude Opus 5 (1M context) --- apps/pwa/src/lib/moshpit-name.mjs | 5 +++++ apps/pwa/src/moshpit.mjs | 2 +- apps/pwa/src/routes/moshpit.mjs | 3 ++- apps/pwa/test/moshpit-bulk-claim.test.mjs | 11 +++++++++++ 4 files changed, 19 insertions(+), 2 deletions(-) diff --git a/apps/pwa/src/lib/moshpit-name.mjs b/apps/pwa/src/lib/moshpit-name.mjs index f798557..dace1fe 100644 --- a/apps/pwa/src/lib/moshpit-name.mjs +++ b/apps/pwa/src/lib/moshpit-name.mjs @@ -136,6 +136,11 @@ export const MAX_BULK_TLDS = 1000; */ export const BULK_TIME_BUDGET_MS = 20_000; +/** 1000 -> "1k". A ceiling is a rough promise and should read like one. */ +export function shortCount(n) { + return n >= 1000 && n % 1000 === 0 ? `${n / 1000}k` : String(n); +} + /** * The most a child name may cost per year. * diff --git a/apps/pwa/src/moshpit.mjs b/apps/pwa/src/moshpit.mjs index b3b48b1..e399f07 100644 --- a/apps/pwa/src/moshpit.mjs +++ b/apps/pwa/src/moshpit.mjs @@ -14,7 +14,7 @@ import { get, all, run } from "./db.mjs"; import { BULK_TIME_BUDGET_MS, MAX_BULK_TLDS, MAX_CHILD_PRICE_USD, normalizeLabel, normalizeTld, parseMoshpitName, parseTldList, tldRejection } from "./lib/moshpit-name.mjs"; export { - RESERVED_TLDS, RESOLVE_MODES, MAX_BULK_TLDS, BULK_TIME_BUDGET_MS, DEFAULT_TLD_PRICE_USD, MAX_CHILD_PRICE_USD, normalizeLabel, normalizeTld, parseMoshpitName, + RESERVED_TLDS, RESOLVE_MODES, MAX_BULK_TLDS, BULK_TIME_BUDGET_MS, shortCount, DEFAULT_TLD_PRICE_USD, MAX_CHILD_PRICE_USD, normalizeLabel, normalizeTld, parseMoshpitName, parseTldList, tldRejection, normalizeMode, resolutionPreference, } from "./lib/moshpit-name.mjs"; diff --git a/apps/pwa/src/routes/moshpit.mjs b/apps/pwa/src/routes/moshpit.mjs index 585a152..fbf64c3 100644 --- a/apps/pwa/src/routes/moshpit.mjs +++ b/apps/pwa/src/routes/moshpit.mjs @@ -54,6 +54,7 @@ import { setExempt, setNameTarget, setTldPrice, + shortCount, summarizeBulkClaim, tldRejection, } from "../moshpit.mjs"; @@ -427,7 +428,7 @@ oranges, pears, plums # anything on a line beats the defaults below · # comments ignored"> ${claimDefaults(req)}

- Up to ${MAX_BULK_TLDS} at a time. Ones already taken are reported, not fatal — + Up to ${shortCount(MAX_BULK_TLDS)} at a time. Ones already taken are reported, not fatal — the rest still land. The price and target below apply to every ending that lands, unless a line says otherwise.

diff --git a/apps/pwa/test/moshpit-bulk-claim.test.mjs b/apps/pwa/test/moshpit-bulk-claim.test.mjs index b210f9d..ada3646 100644 --- a/apps/pwa/test/moshpit-bulk-claim.test.mjs +++ b/apps/pwa/test/moshpit-bulk-claim.test.mjs @@ -398,3 +398,14 @@ test("a paste bigger than one request can finish", { skip: installed ? false : " assert.match(m.summarizeBulkClaim(result), /2 not attempted — paste them again/); }); }); + +test("a ceiling reads like a rough promise", async () => { + const { shortCount, MAX_BULK_TLDS } = await import("../src/lib/moshpit-name.mjs"); + assert.equal(shortCount(1000), "1k"); + assert.equal(shortCount(2000), "2k"); + assert.equal(shortCount(MAX_BULK_TLDS), "1k"); + // Anything not a round thousand stays exact — "1.2k" would be a lie about + // where the ceiling actually is. + assert.equal(shortCount(200), "200"); + assert.equal(shortCount(1500), "1500"); +});