|
10 | 10 | // without a mirror being able to forge or seize a name, because the order is |
11 | 11 | // checkable rather than trusted. |
12 | 12 |
|
13 | | -import { get, all, run } from "./db.mjs"; |
14 | | -import { BULK_TIME_BUDGET_MS, MAX_BULK_TLDS, MAX_CHILD_PRICE_USD, normalizeLabel, normalizeTld, parseMoshpitName, parseTldList, tldRejection } from "./lib/moshpit-name.mjs"; |
| 13 | +import { db, get, all, run } from "./db.mjs"; |
| 14 | +import { |
| 15 | + BULK_CHUNK, |
| 16 | + BULK_TIME_BUDGET_MS, |
| 17 | + MAX_BULK_TLDS, |
| 18 | + MAX_CHILD_PRICE_USD, |
| 19 | + normalizeLabel, |
| 20 | + normalizeTld, |
| 21 | + parseMoshpitName, |
| 22 | + parseTldList, |
| 23 | + tldRejection, |
| 24 | +} from "./lib/moshpit-name.mjs"; |
15 | 25 |
|
16 | 26 | export { |
17 | | - RESERVED_TLDS, RESOLVE_MODES, MAX_BULK_TLDS, BULK_TIME_BUDGET_MS, shortCount, DEFAULT_TLD_PRICE_USD, MAX_CHILD_PRICE_USD, CHILD_PRICE_USD, ENDING_PRICE_USD, normalizeLabel, normalizeTld, parseMoshpitName, |
| 27 | + RESERVED_TLDS, RESOLVE_MODES, MAX_BULK_TLDS, BULK_CHUNK, BULK_TIME_BUDGET_MS, shortCount, DEFAULT_TLD_PRICE_USD, MAX_CHILD_PRICE_USD, CHILD_PRICE_USD, ENDING_PRICE_USD, normalizeLabel, normalizeTld, parseMoshpitName, |
18 | 28 | parseTldList, tldRejection, normalizeMode, resolutionPreference, |
19 | 29 | } from "./lib/moshpit-name.mjs"; |
20 | 30 |
|
@@ -578,80 +588,100 @@ export async function removePin({ tld: tldInput, label: labelInput, pin, userId |
578 | 588 | */ |
579 | 589 | export async function registerTlds({ |
580 | 590 | input, userId, ownerEmail = null, limit = MAX_BULK_TLDS, priceUsd = null, aliasOf = null, |
581 | | - budgetMs = BULK_TIME_BUDGET_MS, now = Date.now, |
| 591 | + chunkSize = BULK_CHUNK, |
582 | 592 | }) { |
583 | 593 | const { entries, skipped } = parseTldList(input, limit); |
584 | | - const deadline = now() + budgetMs; |
585 | | - const remaining = []; |
586 | 594 |
|
587 | 595 | const claimed = []; |
588 | 596 | const mine = []; |
589 | 597 | const taken = []; |
590 | 598 | const rejected = []; |
591 | 599 | const settingsFailed = []; |
592 | 600 |
|
593 | | - for (const [index, entry] of entries.entries()) { |
594 | | - // Checked before the write, not after: stopping with a claim half-made is |
595 | | - // the one outcome worse than stopping early. |
596 | | - if (index > 0 && now() >= deadline) { |
597 | | - remaining.push(...entries.slice(index).map((e) => e.tld)); |
598 | | - break; |
| 601 | + // Validation first, in memory. A reserved or malformed ending never needs a |
| 602 | + // round trip to be refused, and filtering here keeps the batches below to |
| 603 | + // things that can actually land. |
| 604 | + const candidates = []; |
| 605 | + for (const entry of entries) { |
| 606 | + const tld = normalizeTld(entry.tld); |
| 607 | + if (!tld) { rejected.push({ tld: entry.tld, error: "not a valid TLD — letters, digits and dashes only, no dots" }); continue; } |
| 608 | + const why = tldRejection(tld); |
| 609 | + if (why) { rejected.push({ tld, error: why }); continue; } |
| 610 | + candidates.push({ ...entry, tld }); |
| 611 | + } |
| 612 | + |
| 613 | + const at = Date.now(); |
| 614 | + |
| 615 | + for (const chunk of chunksOf(candidates, chunkSize)) { |
| 616 | + // One round trip for the whole chunk. `INSERT OR IGNORE` cannot fail on a |
| 617 | + // name someone already holds, so the batch never rolls back on a |
| 618 | + // collision, and rowsAffected says which of them landed — which is exactly |
| 619 | + // the claimed/taken split, without a SELECT per ending. |
| 620 | + const inserted = await db.batch( |
| 621 | + chunk.map((c) => ({ |
| 622 | + sql: `INSERT OR IGNORE INTO moshpit_tlds (tld, user_id, owner_email, owner_key, created_at) VALUES (?,?,?,?,?)`, |
| 623 | + args: [c.tld, userId, ownerEmail, null, at], |
| 624 | + })), |
| 625 | + "write", |
| 626 | + ); |
| 627 | + |
| 628 | + const landed = []; |
| 629 | + const collided = []; |
| 630 | + chunk.forEach((c, i) => (inserted[i].rowsAffected ? landed : collided).push(c)); |
| 631 | + |
| 632 | + // Who holds the ones that collided — one query for all of them, so that |
| 633 | + // "already yours" stays distinguishable from "someone else has it" without |
| 634 | + // costing a lookup each. |
| 635 | + if (collided.length) { |
| 636 | + const owners = await all( |
| 637 | + `SELECT tld, user_id FROM moshpit_tlds WHERE tld IN (${collided.map(() => "?").join(",")})`, |
| 638 | + collided.map((c) => c.tld), |
| 639 | + ); |
| 640 | + const byTld = new Map(owners.map((row) => [row.tld, row.user_id])); |
| 641 | + for (const c of collided) (byTld.get(c.tld) === userId ? mine : taken).push(c.tld); |
599 | 642 | } |
600 | | - const tld = entry.tld; |
601 | | - const result = await registerTld({ tld, userId, ownerEmail }); |
602 | | - if (result.ok) { |
603 | | - claimed.push(result.tld.tld); |
604 | | - // Settings are applied per ending, and a failure here is reported rather |
605 | | - // than thrown: the ending is already claimed and keeping it is the point. |
606 | | - // Losing a whole batch because one alias target was wrong would be worse |
607 | | - // than landing forty endings with no price on them. |
608 | | - // A value written on the line wins over the form's, in either direction: |
609 | | - // the form is the default for the whole paste, the line is what this one |
610 | | - // ending is actually worth. |
611 | | - const failure = await applyTldDefaults({ |
612 | | - tld: result.tld.tld, |
613 | | - userId, |
614 | | - priceUsd: entry.priceUsd ?? priceUsd, |
615 | | - aliasOf: entry.aliasOf ?? aliasOf, |
| 643 | + |
| 644 | + if (!landed.length) continue; |
| 645 | + claimed.push(...landed.map((c) => c.tld)); |
| 646 | + |
| 647 | + // Price and alias fold into a single UPDATE per ending rather than the |
| 648 | + // read-check-write setTldPrice does: ownership was just established by the |
| 649 | + // INSERT above, so re-reading the row to confirm it would be asking a |
| 650 | + // question already answered. |
| 651 | + const updates = []; |
| 652 | + for (const c of landed) { |
| 653 | + const price = normalizePrice(c.priceUsd ?? priceUsd); |
| 654 | + const alias = normalizeTld(c.aliasOf ?? aliasOf); |
| 655 | + if (price === undefined) { settingsFailed.push({ tld: c.tld, error: "price must be a positive number" }); continue; } |
| 656 | + const target = alias && alias !== c.tld ? alias : null; |
| 657 | + if (price === null && !target) continue; |
| 658 | + updates.push({ |
| 659 | + sql: `UPDATE moshpit_tlds SET price_usd = COALESCE(?, price_usd), alias_of = COALESCE(?, alias_of) WHERE tld = ? AND user_id = ?`, |
| 660 | + args: [price, target, c.tld, userId], |
616 | 661 | }); |
617 | | - if (failure) settingsFailed.push({ tld: result.tld.tld, error: failure }); |
618 | | - continue; |
619 | 662 | } |
620 | 663 |
|
621 | | - if (result.taken) { |
622 | | - // Re-pasting a list you already claimed should read as "already yours", |
623 | | - // not as a collision with a stranger. |
624 | | - const owner = await getTld(tld); |
625 | | - (owner?.user_id === userId ? mine : taken).push(tld); |
626 | | - continue; |
627 | | - } |
628 | | - rejected.push({ tld, error: result.error }); |
| 664 | + const logs = landed.map((c) => ({ |
| 665 | + sql: `INSERT INTO moshpit_tld_log (tld, user_id, action, at) VALUES (?,?,?,?)`, |
| 666 | + args: [c.tld, userId, "register", at], |
| 667 | + })); |
| 668 | + |
| 669 | + if (updates.length || logs.length) await db.batch([...updates, ...logs], "write"); |
629 | 670 | } |
630 | 671 |
|
631 | | - return { claimed, mine, taken, rejected, settingsFailed, skipped, remaining, attempted: entries.length }; |
| 672 | + return { claimed, mine, taken, rejected, settingsFailed, skipped, remaining: [], attempted: entries.length }; |
632 | 673 | } |
633 | 674 |
|
634 | | -/** |
635 | | - * Apply the whole-list settings to one freshly claimed ending. |
636 | | - * |
637 | | - * Returns an error string, or null when there was nothing to do or it worked. |
638 | | - * Aliasing an ending to itself is silently skipped rather than reported: it is |
639 | | - * what you get by pasting a list that happens to contain the alias target, and |
640 | | - * refusing the whole entry over it would be pedantic. |
641 | | - */ |
642 | | -async function applyTldDefaults({ tld, userId, priceUsd, aliasOf }) { |
643 | | - const wantsPrice = priceUsd !== null && priceUsd !== undefined && String(priceUsd).trim() !== ""; |
644 | | - if (wantsPrice) { |
645 | | - const priced = await setTldPrice({ tld, userId, priceUsd }); |
646 | | - if (!priced.ok) return priced.error; |
647 | | - } |
| 675 | +function* chunksOf(list, size) { |
| 676 | + for (let i = 0; i < list.length; i += size) yield list.slice(i, i + size); |
| 677 | +} |
648 | 678 |
|
649 | | - const target = normalizeTld(aliasOf); |
650 | | - if (target && target !== tld) { |
651 | | - const aliased = await setAlias({ from: tld, to: target, userId }); |
652 | | - if (!aliased.ok) return aliased.error; |
653 | | - } |
654 | | - return null; |
| 679 | +/** null = leave alone, undefined = refuse, a number = set it. */ |
| 680 | +function normalizePrice(value) { |
| 681 | + if (value === null || value === undefined || String(value).trim() === "") return null; |
| 682 | + const price = Number(value); |
| 683 | + if (!Number.isFinite(price) || price <= 0 || price > 1_000_000) return undefined; |
| 684 | + return Math.round(price * 100) / 100; |
655 | 685 | } |
656 | 686 |
|
657 | 687 | /** One line fit for a flash message: what landed, what did not, and why. */ |
|
0 commit comments