openvibers/openvibe.host Commits
Readiness waits for the offers, domain and benchmark tables again (0009-0012 applied 2026-10-19 00:47 UTC), and their 'before the migration applies' fallbacks are gone: offers, domain prices, the TLD search documents and benchmarks read their tables directly (#133)
Changes
@@@ -5,11 +5,10 @@* JSON result to that link; Host keeps the measurements and shows them per provider and plan.** parseYabs(body) YABS's JSON → the stored fields, or throws ApiError 400-- * createBenchmarks({ store, baseUrl }) → { createLink, submit, list, get, summary, remove, ready }++ * createBenchmarks({ store, baseUrl }) → { createLink, submit, list, get, summary, remove }** A link works once, for 24 hours; a person gets 10 a day. The server's address is never stored: YABS's own lookup gives-- * the city and network, which are kept. Until 0012 has applied (a held contract migration holds it), reads answer empty-- * and `ready()` is false.++ * the city and network, which are kept.*/const crypto = require('crypto');const { newId } = require('../ids');@@@ -22,7 +21,6 @@ const YABS_COMMAND = (url) => `curl -sL https://yabs.sh | bash -s -- -s "${url}"const bad = (message) => { throw new ApiError(400, 'host.benchmark.invalid', message); };const invalid = (message) => { throw new ApiError(422, 'host.catalog.invalid', message); };--const missingTable = (err) => err && err.code === '42P01';const sha = (s) => crypto.createHash('sha256').update(s).digest('hex');const text = (v, max) => (typeof v === 'string' && v.trim() ? v.trim().replace(/[\u0000-\u001f\u007f]/g, ' ').slice(0, max) : null);const numberIn = (v, max) => (typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= max ? v : null);@@@ -77,21 +75,10 @@ const SORTS = {function createBenchmarks({ store, baseUrl }) {const db = store.db;-- let isReady = null;-- async function ready() {-- if (isReady) return true;-- const r = await db.prepare("SELECT to_regclass('catalog_benchmarks') AS t").get();-- isReady = Boolean(r && r.t);-- return isReady;-- }-- async function safe(fn, fallback) {-- try { return await fn(); } catch (err) { if (missingTable(err)) { isReady = false; return fallback; } throw err; }-- }/** A one-time submit link for `person`, naming a catalog provider (by slug) or another provider by name, and a plan. */async function createLink(person, input = {}) {if (!person) throw new ApiError(401, 'host.benchmark.sign_in', 'Sign in to submit a benchmark');-- if (!(await ready())) throw new ApiError(503, 'host.benchmark.unavailable', 'Benchmarks open after the next maintenance');const now = store.now();const recent = await db.prepare('SELECT COUNT(*) AS n FROM catalog_bench_links WHERE person = ? AND created_at > ?').get(person, now - 86400_000);if (Number(recent.n) >= LINKS_PER_DAY) throw new ApiError(429, 'host.benchmark.too_many', `At most ${LINKS_PER_DAY} benchmark links a day`);@@@ -128,7 +115,7 @@ function createBenchmarks({ store, baseUrl }) {/** Before reading a posted body: the link exists, is unused and unexpired. */async function checkLink(token) {if (!tokenOk(token)) usable(null);-- const link = await safe(() => db.prepare('SELECT used_at, expires_at FROM catalog_bench_links WHERE token_hash = ?').get(sha(token)), null);++ const link = await db.prepare('SELECT used_at, expires_at FROM catalog_bench_links WHERE token_hash = ?').get(sha(token));usable(link, store.now());}@@@ -170,14 +157,12 @@ function createBenchmarks({ store, baseUrl }) {const args = [];if (query.provider) { where += ' AND (v.slug = ? OR lower(b.provider_name) = lower(?))'; args.push(String(query.provider), String(query.provider)); }if (query.plan) { where += ' AND b.plan_id = ?'; args.push(String(query.plan)); }-- return await safe(async () => {-- const rows = await db.prepare(`${SELECT} WHERE ${where} ORDER BY ${SORTS[sort]} LIMIT ${PAGE + 1} OFFSET ${offset}`).all(...args);-- return { benchmarks: rows.slice(0, PAGE).map(out), next_cursor: rows.length > PAGE ? String(offset + PAGE) : null };-- }, { benchmarks: [], next_cursor: null });++ const rows = await db.prepare(`${SELECT} WHERE ${where} ORDER BY ${SORTS[sort]} LIMIT ${PAGE + 1} OFFSET ${offset}`).all(...args);++ return { benchmarks: rows.slice(0, PAGE).map(out), next_cursor: rows.length > PAGE ? String(offset + PAGE) : null };}async function get(id, viewer = null) {-- const row = await safe(() => db.prepare(`${SELECT} WHERE b.id = ? AND b.status = 'active'`).get(String(id)), null);++ const row = await db.prepare(`${SELECT} WHERE b.id = ? AND b.status = 'active'`).get(String(id));if (!row) throw new ApiError(404, 'host.catalog.not_found', 'No such benchmark');return { ...out(row), mine: Boolean(viewer && viewer.subject && viewer.subject === row.submitted_by) };}@@@ -185,14 +170,12 @@ function createBenchmarks({ store, baseUrl }) {/** The medians for a provider or a plan (a provider page's and a plan page's line). */async function summary({ providerId, planId }) {const [col, val] = planId ? ['b.plan_id', planId] : ['b.provider_id', providerId];-- return await safe(async () => {-- const r = await db.prepare(`SELECT COUNT(*) AS n, percentile_cont(0.5) WITHIN GROUP (ORDER BY b.gb_single) AS gb_single, percentile_cont(0.5) WITHIN GROUP (ORDER BY b.gb_multi) AS gb_multi,-- percentile_cont(0.5) WITHIN GROUP (ORDER BY b.disk_4k_iops) AS disk_4k_iops, MAX(b.gb_version) AS gb_version, MAX(b.created_at) AS latest-- FROM catalog_benchmarks b WHERE ${col} = ? AND b.status = 'active'`).get(val);-- const n = Number(r.n) || 0;-- return n ? { count: n, gb_single: r.gb_single == null ? null : Math.round(Number(r.gb_single)), gb_multi: r.gb_multi == null ? null : Math.round(Number(r.gb_multi)),-- disk_4k_iops: r.disk_4k_iops == null ? null : Math.round(Number(r.disk_4k_iops)), gb_version: r.gb_version == null ? null : Number(r.gb_version), latest: new Date(Number(r.latest)).toISOString() } : { count: 0 };-- }, { count: 0 });++ const r = await db.prepare(`SELECT COUNT(*) AS n, percentile_cont(0.5) WITHIN GROUP (ORDER BY b.gb_single) AS gb_single, percentile_cont(0.5) WITHIN GROUP (ORDER BY b.gb_multi) AS gb_multi,++ percentile_cont(0.5) WITHIN GROUP (ORDER BY b.disk_4k_iops) AS disk_4k_iops, MAX(b.gb_version) AS gb_version, MAX(b.created_at) AS latest++ FROM catalog_benchmarks b WHERE ${col} = ? AND b.status = 'active'`).get(val);++ const n = Number(r.n) || 0;++ return n ? { count: n, gb_single: r.gb_single == null ? null : Math.round(Number(r.gb_single)), gb_multi: r.gb_multi == null ? null : Math.round(Number(r.gb_multi)),++ disk_4k_iops: r.disk_4k_iops == null ? null : Math.round(Number(r.disk_4k_iops)), gb_version: r.gb_version == null ? null : Number(r.gb_version), latest: new Date(Number(r.latest)).toISOString() } : { count: 0 };}/** The person who submitted a result, or staff, takes it down. */@@@ -203,7 +186,7 @@ function createBenchmarks({ store, baseUrl }) {await db.prepare("UPDATE catalog_benchmarks SET status = 'hidden', updated_at = ? WHERE id = ?").run(store.now(), String(id));}-- return { createLink, checkLink, submit, list, get, summary, remove, ready, SORTS: Object.keys(SORTS) };++ return { createLink, checkLink, submit, list, get, summary, remove, SORTS: Object.keys(SORTS) };}module.exports = { parseYabs, createBenchmarks, YABS_COMMAND, LINK_TTL_MS, LINKS_PER_DAY };@@@ -2,9 +2,6 @@/*** Domain prices in the catalog (migrations/0011_catalog_domains.sql, server/catalog/domains.js): one row per registrar* and TLD, applied from Sources items; the TLD table, a TLD's page and the cheapest price for a list of endings.-- *-- * Until 0011 has applied (a held contract migration holds every later one) the tables do not exist: reads answer-- * empty and `ready()` is false, so pages leave the price sections out rather than fail.*/const { newId } = require('../ids');const { ApiError } = require('../http/errors');@@@ -28,24 +25,12 @@ const KINDS = {'second-level': "position('.' in p.tld) > 0",internationalized: "position('.' in p.tld) = 0 AND p.tld LIKE 'xn--%'",};--const missingTable = (err) => err && err.code === '42P01';const n = (v) => (v == null ? null : Number(v));const iso = (ms) => (ms == null ? null : new Date(Number(ms)).toISOString());function createDomainStore({ store, announce = () => {} }) {const db = store.db;let known = { at: 0, set: new Set() };-- let isReady = null;---- async function ready() {-- if (isReady) return true;-- const r = await db.prepare("SELECT to_regclass('catalog_tld_prices') AS t").get();-- isReady = Boolean(r && r.t);-- return isReady;-- }-- async function safe(fn, fallback) {-- try { return await fn(); } catch (err) { if (missingTable(err)) { isReady = false; return fallback; } throw err; }-- }async function apply(item, t = db) {const now = store.now();@@@ -115,24 +100,20 @@ function createDomainStore({ store, announce = () => {} }) {if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(String(query.registrar))) invalid('Invalid registrar');where += ' AND v.slug = ?'; args.push(query.registrar);}-- return await safe(async () => {-- const rows = await db.prepare(`SELECT p.tld, 'USD' AS currency, MIN(p.register) AS register, MIN(p.renew) AS renew, MIN(p.transfer) AS transfer, MIN(${FIVE_YEAR}) AS five_year, COUNT(*) AS registrars, MAX(p.checked_at) AS checked_at-- FROM catalog_tld_prices p JOIN catalog_providers v ON v.id = p.provider_id WHERE ${where} GROUP BY p.tld ORDER BY ${SORTS[sort]} LIMIT ${PAGE + 1} OFFSET ${offset}`).all(...args);-- return { tlds: rows.slice(0, PAGE).map(tldRow), next_cursor: rows.length > PAGE ? String(offset + PAGE) : null };-- }, { tlds: [], next_cursor: null });++ const rows = await db.prepare(`SELECT p.tld, 'USD' AS currency, MIN(p.register) AS register, MIN(p.renew) AS renew, MIN(p.transfer) AS transfer, MIN(${FIVE_YEAR}) AS five_year, COUNT(*) AS registrars, MAX(p.checked_at) AS checked_at++ FROM catalog_tld_prices p JOIN catalog_providers v ON v.id = p.provider_id WHERE ${where} GROUP BY p.tld ORDER BY ${SORTS[sort]} LIMIT ${PAGE + 1} OFFSET ${offset}`).all(...args);++ return { tlds: rows.slice(0, PAGE).map(tldRow), next_cursor: rows.length > PAGE ? String(offset + PAGE) : null };}/** One TLD: every registrar's price (cheapest first year first) and the latest price changes. */async function tld(input) {const t = domains.normalizeTld(String(input || ''));if (!t) throw new ApiError(404, 'host.catalog.not_found', 'Not a domain ending');-- const data = await safe(async () => {-- const rows = await db.prepare(`SELECT p.*, ${FIVE_YEAR} AS five_year, v.slug AS provider_slug, v.name AS provider_name, v.homepage_url, v.affiliate_url, v.affiliate_note-- FROM catalog_tld_prices p JOIN catalog_providers v ON v.id = p.provider_id WHERE ${ACTIVE} AND p.tld = ? ORDER BY p.register ASC NULLS LAST, v.name, p.id`).all(t);-- const history = rows.length ? await db.prepare(`SELECT h.*, v.name AS provider_name FROM catalog_tld_price_changes h JOIN catalog_tld_prices p ON p.id = h.price_id JOIN catalog_providers v ON v.id = p.provider_id-- WHERE p.tld = ? AND ${ACTIVE} ORDER BY h.observed_at DESC LIMIT 20`).all(t) : [];-- return { rows, history };-- }, { rows: [], history: [] });++ const rows = await db.prepare(`SELECT p.*, ${FIVE_YEAR} AS five_year, v.slug AS provider_slug, v.name AS provider_name, v.homepage_url, v.affiliate_url, v.affiliate_note++ FROM catalog_tld_prices p JOIN catalog_providers v ON v.id = p.provider_id WHERE ${ACTIVE} AND p.tld = ? ORDER BY p.register ASC NULLS LAST, v.name, p.id`).all(t);++ const history = rows.length ? await db.prepare(`SELECT h.*, v.name AS provider_name FROM catalog_tld_price_changes h JOIN catalog_tld_prices p ON p.id = h.price_id JOIN catalog_providers v ON v.id = p.provider_id++ WHERE p.tld = ? AND ${ACTIVE} ORDER BY h.observed_at DESC LIMIT 20`).all(t) : [];++ const data = { rows, history };if (!data.rows.length) throw new ApiError(404, 'host.catalog.not_found', 'No registrar price is listed for this ending');const prices = data.rows.map((r) => ({registrar: r.provider_name, registrar_slug: r.provider_slug, homepage_url: r.homepage_url, affiliate_url: r.affiliate_url, affiliate_note: r.affiliate_note,@@@ -154,8 +135,8 @@ function createDomainStore({ store, announce = () => {} }) {async function cheapest(tlds) {const list = [...new Set(tlds.map((x) => domains.normalizeTld(x)).filter(Boolean))].slice(0, 30);if (!list.length) return [];-- const rows = await safe(() => db.prepare(`SELECT p.tld, 'USD' AS currency, MIN(p.register) AS register, MIN(p.renew) AS renew, MIN(p.transfer) AS transfer, MIN(${FIVE_YEAR}) AS five_year, COUNT(*) AS registrars, MAX(p.checked_at) AS checked_at-- FROM catalog_tld_prices p JOIN catalog_providers v ON v.id = p.provider_id WHERE ${ACTIVE} AND p.currency = 'USD' AND p.tld IN (${list.map(() => '?').join(', ')}) GROUP BY p.tld`).all(...list), []);++ const rows = await db.prepare(`SELECT p.tld, 'USD' AS currency, MIN(p.register) AS register, MIN(p.renew) AS renew, MIN(p.transfer) AS transfer, MIN(${FIVE_YEAR}) AS five_year, COUNT(*) AS registrars, MAX(p.checked_at) AS checked_at++ FROM catalog_tld_prices p JOIN catalog_providers v ON v.id = p.provider_id WHERE ${ACTIVE} AND p.currency = 'USD' AND p.tld IN (${list.map(() => '?').join(', ')}) GROUP BY p.tld`).all(...list);const by = new Map(rows.map((r) => [r.tld, tldRow(r)]));return list.filter((t) => by.has(t)).map((t) => by.get(t));}@@@ -163,29 +144,27 @@ function createDomainStore({ store, announce = () => {} }) {/** Every TLD with a listed price (for reading what a person typed), refreshed every five minutes. */async function knownTlds() {if (store.now() - known.at < 5 * 60_000) return known.set;-- const rows = await safe(() => db.prepare(`SELECT DISTINCT p.tld FROM catalog_tld_prices p JOIN catalog_providers v ON v.id = p.provider_id WHERE ${ACTIVE}`).all(), []);++ const rows = await db.prepare(`SELECT DISTINCT p.tld FROM catalog_tld_prices p JOIN catalog_providers v ON v.id = p.provider_id WHERE ${ACTIVE}`).all();known = { at: store.now(), set: new Set(rows.map((r) => r.tld)) };return known.set;}async function stats() {-- return await safe(async () => {-- const r = await db.prepare(`SELECT COUNT(DISTINCT p.tld) AS tlds, MAX(p.checked_at) AS last FROM catalog_tld_prices p JOIN catalog_providers v ON v.id = p.provider_id WHERE ${ACTIVE}`).get();-- const names = await db.prepare(`SELECT DISTINCT v.name FROM catalog_tld_prices p JOIN catalog_providers v ON v.id = p.provider_id WHERE ${ACTIVE} ORDER BY v.name`).all();-- return { tlds: Number(r.tlds) || 0, registrars: names.map((x) => x.name), lastChecked: r.last == null ? null : Number(r.last) };-- }, { tlds: 0, registrars: [], lastChecked: null });++ const r = await db.prepare(`SELECT COUNT(DISTINCT p.tld) AS tlds, MAX(p.checked_at) AS last FROM catalog_tld_prices p JOIN catalog_providers v ON v.id = p.provider_id WHERE ${ACTIVE}`).get();++ const names = await db.prepare(`SELECT DISTINCT v.name FROM catalog_tld_prices p JOIN catalog_providers v ON v.id = p.provider_id WHERE ${ACTIVE} ORDER BY v.name`).all();++ return { tlds: Number(r.tlds) || 0, registrars: names.map((x) => x.name), lastChecked: r.last == null ? null : Number(r.last) };}async function sitemap() {-- return await safe(() => db.prepare(`SELECT p.tld, MAX(p.checked_at) AS checked_at FROM catalog_tld_prices p JOIN catalog_providers v ON v.id = p.provider_id WHERE ${ACTIVE} GROUP BY p.tld ORDER BY p.tld`).all(), []);++ return db.prepare(`SELECT p.tld, MAX(p.checked_at) AS checked_at FROM catalog_tld_prices p JOIN catalog_providers v ON v.id = p.provider_id WHERE ${ACTIVE} GROUP BY p.tld ORDER BY p.tld`).all();}/** How many TLDs each registrar lists (a provider page's domain line). */async function countFor(providerId) {-- return await safe(async () => Number((await db.prepare("SELECT COUNT(*) AS n FROM catalog_tld_prices WHERE provider_id = ? AND status = 'active'").get(providerId)).n) || 0, 0);++ return Number((await db.prepare("SELECT COUNT(*) AS n FROM catalog_tld_prices WHERE provider_id = ? AND status = 'active'").get(providerId)).n) || 0;}-- return { apply, list, tld, cheapest, knownTlds, stats, sitemap, countFor, ready, SORTS: Object.keys(SORTS), KINDS: Object.keys(KINDS) };++ return { apply, list, tld, cheapest, knownTlds, stats, sitemap, countFor, SORTS: Object.keys(SORTS), KINDS: Object.keys(KINDS) };}module.exports = { createDomainStore };@@@ -89,9 +89,6 @@ function tldDocument(row) {};}--// Before migration 0011 applies (a held contract holds it) there is no price table: no ending to send yet.--const orNone = (p) => p.catch((err) => { if (err && err.code === '42P01') return []; throw err; });--function createCatalogSearch({ config, store, outbox, log = console }) {const db = store.db;const feed = createSearchFeed({@@@ -119,7 +116,7 @@ function createCatalogSearch({ config, store, outbox, log = console }) {page: (row) => `/catalog/domains/${encodeURIComponent(row.tld)}`,document: tldDocument,// Every ending any registrar has listed: its active prices aggregated (none active: unlisted, a tombstone).-- rows: (after, limit) => orNone(db.many(`SELECT 'tld-' || p.tld AS id, p.tld,++ rows: (after, limit) => db.many(`SELECT 'tld-' || p.tld AS id, p.tld,MIN(p.register) FILTER (WHERE p.status = 'active' AND v.status = 'active') AS register,MIN(p.renew) FILTER (WHERE p.status = 'active' AND v.status = 'active') AS renew,MIN(COALESCE(p.register, p.renew) + 4 * COALESCE(p.renew, p.register)) FILTER (WHERE p.status = 'active' AND v.status = 'active') AS five_year,@@@ -128,8 +125,8 @@ function createCatalogSearch({ config, store, outbox, log = console }) {(array_agg(p.source_url ORDER BY p.register NULLS LAST))[1] AS source_url,(array_agg(v.name ORDER BY p.register NULLS LAST))[1] AS registrarFROM catalog_tld_prices p JOIN catalog_providers v ON v.id = p.provider_id-- GROUP BY p.tld HAVING 'tld-' || p.tld > $1 ORDER BY 1 LIMIT $2`, [after, limit])),-- exists: async (ids) => (await orNone(db.many("SELECT DISTINCT 'tld-' || tld AS id FROM catalog_tld_prices WHERE 'tld-' || tld = ANY($1)", [ids]))).map((r) => r.id),++ GROUP BY p.tld HAVING 'tld-' || p.tld > $1 ORDER BY 1 LIMIT $2`, [after, limit]),++ exists: async (ids) => (await db.many("SELECT DISTINCT 'tld-' || tld AS id FROM catalog_tld_prices WHERE 'tld-' || tld = ANY($1)", [ids])).map((r) => r.id),},},});@@@ -193,14 +193,7 @@ function createCatalog({ store, community = null, log = console, indexnow = null}const offset = query.cursor == null ? 0 : Number(query.cursor);if (!Number.isSafeInteger(offset) || offset < 0) invalid('Invalid cursor');-- let rows;-- try {-- rows = await db.prepare(`SELECT * FROM catalog_offers WHERE ${where} ORDER BY ${OFFER_SORTS[sort]} LIMIT 51 OFFSET ${offset}`).all(...args);-- } catch (err) {-- // Before migration 0009 has applied (a held contract migration holds it), there are no offers yet.-- if (err && err.code === '42P01') return { offers: [], next_cursor: null };-- throw err;-- }++ const rows = await db.prepare(`SELECT * FROM catalog_offers WHERE ${where} ORDER BY ${OFFER_SORTS[sort]} LIMIT 51 OFFSET ${offset}`).all(...args);return { offers: rows.slice(0, 50).map(offerOut), next_cursor: rows.length > 50 ? String(offset + 50) : null };}const planOut = (p) => ({ ...p, affiliate_rel: p.affiliate_url ? 'sponsored nofollow noopener' : null, affiliate_disclosure: p.affiliate_url ? 'OpenVibe may earn a commission from links marked as affiliate links; it never changes the order or the prices shown.' : null, price_per_vcpu: p.vcpu > 0 ? Number(p.price_monthly) / Number(p.vcpu) : null, price_per_gb_ram: p.ram_mb > 0 ? Number(p.price_monthly) * 1024 / Number(p.ram_mb) : null, price_per_gb_disk: p.disk_gb > 0 ? Number(p.price_monthly) / Number(p.disk_gb) : null, checked_at: new Date(Number(p.checked_at)).toISOString() });@@@ -9,9 +9,8 @@ const { createDiscussionRefs } = require('openvibe-publishing/discussion');const MIGRATIONS = path.join(__dirname, '..', 'migrations');const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');--// catalog_offers (0009) is not required yet: the 0008 contract holds every later migration until 2026-10-19 00:37 UTC--// (sdk/db keeps the order), and the offers page shows none until then. Required again once 0009 has applied.--const CHARTER_TABLES = ['host_catalog_discussion_refs', 'catalog_providers', 'catalog_plans', 'catalog_price_changes', 'catalog_promos', 'catalog_ingest_cursor'];++const CHARTER_TABLES = ['host_catalog_discussion_refs', 'catalog_providers', 'catalog_plans', 'catalog_price_changes', 'catalog_promos', 'catalog_ingest_cursor',++ 'catalog_offers', 'catalog_tld_prices', 'catalog_tld_price_changes', 'catalog_bench_links', 'catalog_benchmarks'];/*** The serving handle (ADR-035): DATABASE_URL through PgBouncer; in development without it, an embedded PGlite database@@@ -4,8 +4,7 @@* price list from Sources becomes one price per TLD (Handshake names left out, "2,060.25" read, a change kept as history,* a TLD no longer sold gone); the TLD table sorts popular first, by price or over five years; a TLD's page lists every* registrar; a name check reads what was typed, asks the registry over RDAP (cached, refused redirects, unknown when the-- * ending has no RDAP) and prices the same name at the popular endings; without the tables (a held migration) the pages-- * still answer.++ * ending has no RDAP) and prices the same name at the popular endings; readiness waits for the tables.*/const assert = require('assert');const { boot, check, done } = require('./helpers/boot');@@@ -175,23 +174,9 @@ function fakeRegistry() {assert.deepStrictEqual([empty.status, empty.headers.location], [303, '/catalog/domains']);});-- await check('without the domain tables (a held migration) the pages still answer and a name can be checked', async () => {-- // The app role cannot drop tables on PostgreSQL: every statement naming them answers as PostgreSQL does when they are missing.-- const db = t.ctx.store.db;-- const prepare = db.prepare;-- const missing = () => Promise.reject(Object.assign(new Error('relation "catalog_tld_prices" does not exist'), { code: '42P01' }));-- db.prepare = (sql) => (/catalog_tld_/.test(sql) ? { all: missing, get: missing, run: missing } : prepare.call(db, sql));-- const index = await t.api('GET', '/catalog/domains');-- assert.strictEqual(index.status, 200);-- assert.match(index.text, /Is your name free\?/);-- assert.doesNotMatch(index.text, /<table/, 'no empty price table');-- assert.deepStrictEqual((await t.api('GET', '/api/v1/catalog/domains')).json(), { tlds: [], next_cursor: null });-- assert.strictEqual((await t.api('GET', '/catalog/domains/dev')).status, 404);-- assert.strictEqual((await t.api('GET', '/')).status, 200);-- assert.strictEqual((await t.api('GET', '/sitemap.xml')).status, 200);-- assert.strictEqual((await t.api('GET', '/catalog/providers/porkbun')).status, 200);-- assert.strictEqual((await t.api('GET', '/api/v1/catalog/domains/check?name=taken.com')).json().check.status, 'registered');-- db.prepare = prepare;++ await check('readiness waits for the domain tables (a release never answers without them)', async () => {++ const { CHARTER_TABLES } = require('../server/db');++ for (const table of ['catalog_tld_prices', 'catalog_tld_price_changes']) assert.ok(CHARTER_TABLES.includes(table), table);});} finally { await t.close(); }done();@@@ -94,16 +94,8 @@ const item = (key, title, { published = '2026-09-20T10:00:00Z', summary = null,assert.match(front.text, /Latest hosting offers/);assert.match(front.text, /href="\/catalog\/offers"/);});-- await check('before migration 0009 applies (a held contract holds it), offers answer empty and readiness does not need the table', async () => {-- const { createCatalog } = require('../server/catalog/store');-- const missing = Object.assign(new Error('relation "catalog_offers" does not exist'), { code: '42P01' });-- const db = { prepare: () => ({ all: async () => { throw missing; }, get: async () => null, run: async () => ({ changes: 0 }) }) };-- const early = createCatalog({ store: { db, now: () => NOW } });-- assert.deepStrictEqual(await early.offers({}), { offers: [], next_cursor: null });-- assert.ok(!require('../server/db').CHARTER_TABLES.includes('catalog_offers'), 'readiness does not wait for the table');-- const other = Object.assign(new Error('boom'), { code: '57014' });-- const broken = createCatalog({ store: { db: { prepare: () => ({ all: async () => { throw other; } }) }, now: () => NOW } });-- await assert.rejects(broken.offers({}), /boom/, 'any other error still fails');++ await check('readiness waits for the offers table (a release never answers without it)', async () => {++ assert.ok(require('../server/db').CHARTER_TABLES.includes('catalog_offers'));});} finally { await t.close(); }done();
@@ -5,11 +5,10 @@
@@ -5,11 +5,10 @@
* JSON result to that link; Host keeps the measurements and shows them per provider and plan.
* JSON result to that link; Host keeps the measurements and shows them per provider and plan.
*
*
* parseYabs(body) YABS's JSON → the stored fields, or throws ApiError 400
* parseYabs(body) YABS's JSON → the stored fields, or throws ApiError 400
- * createBenchmarks({ store, baseUrl }) → { createLink, submit, list, get, summary, remove, ready }
+ * createBenchmarks({ store, baseUrl }) → { createLink, submit, list, get, summary, remove }
*
*
* A link works once, for 24 hours; a person gets 10 a day. The server's address is never stored: YABS's own lookup gives
* A link works once, for 24 hours; a person gets 10 a day. The server's address is never stored: YABS's own lookup gives
- * the city and network, which are kept. Until 0012 has applied (a held contract migration holds it), reads answer empty
- * and `ready()` is false.
+ * the city and network, which are kept.
*/
*/
const crypto = require('crypto');
const crypto = require('crypto');
const { newId } = require('../ids');
const { newId } = require('../ids');
@@ -22,7 +21,6 @@ const YABS_COMMAND = (url) => `curl -sL https://yabs.sh | bash -s -- -s "${url}"
@@ -22,7 +21,6 @@ const YABS_COMMAND = (url) => `curl -sL https://yabs.sh | bash -s -- -s "${url}"
const bad = (message) => { throw new ApiError(400, 'host.benchmark.invalid', message); };
const bad = (message) => { throw new ApiError(400, 'host.benchmark.invalid', message); };
const invalid = (message) => { throw new ApiError(422, 'host.catalog.invalid', message); };
const invalid = (message) => { throw new ApiError(422, 'host.catalog.invalid', message); };
-const missingTable = (err) => err && err.code === '42P01';
const sha = (s) => crypto.createHash('sha256').update(s).digest('hex');
const sha = (s) => crypto.createHash('sha256').update(s).digest('hex');
const text = (v, max) => (typeof v === 'string' && v.trim() ? v.trim().replace(/[\u0000-\u001f\u007f]/g, ' ').slice(0, max) : null);
const text = (v, max) => (typeof v === 'string' && v.trim() ? v.trim().replace(/[\u0000-\u001f\u007f]/g, ' ').slice(0, max) : null);
const numberIn = (v, max) => (typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= max ? v : null);
const numberIn = (v, max) => (typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= max ? v : null);
@@ -77,21 +75,10 @@ const SORTS = {
@@ -77,21 +75,10 @@ const SORTS = {
function createBenchmarks({ store, baseUrl }) {
function createBenchmarks({ store, baseUrl }) {
const db = store.db;
const db = store.db;
- let isReady = null;
- async function ready() {
- if (isReady) return true;
- const r = await db.prepare("SELECT to_regclass('catalog_benchmarks') AS t").get();
- isReady = Boolean(r && r.t);
- return isReady;
- }
- async function safe(fn, fallback) {
- try { return await fn(); } catch (err) { if (missingTable(err)) { isReady = false; return fallback; } throw err; }
- }
/** A one-time submit link for `person`, naming a catalog provider (by slug) or another provider by name, and a plan. */
/** A one-time submit link for `person`, naming a catalog provider (by slug) or another provider by name, and a plan. */
async function createLink(person, input = {}) {
async function createLink(person, input = {}) {
if (!person) throw new ApiError(401, 'host.benchmark.sign_in', 'Sign in to submit a benchmark');
if (!person) throw new ApiError(401, 'host.benchmark.sign_in', 'Sign in to submit a benchmark');
- if (!(await ready())) throw new ApiError(503, 'host.benchmark.unavailable', 'Benchmarks open after the next maintenance');
const now = store.now();
const now = store.now();
const recent = await db.prepare('SELECT COUNT(*) AS n FROM catalog_bench_links WHERE person = ? AND created_at > ?').get(person, now - 86400_000);
const recent = await db.prepare('SELECT COUNT(*) AS n FROM catalog_bench_links WHERE person = ? AND created_at > ?').get(person, now - 86400_000);
if (Number(recent.n) >= LINKS_PER_DAY) throw new ApiError(429, 'host.benchmark.too_many', `At most ${LINKS_PER_DAY} benchmark links a day`);
if (Number(recent.n) >= LINKS_PER_DAY) throw new ApiError(429, 'host.benchmark.too_many', `At most ${LINKS_PER_DAY} benchmark links a day`);
@@ -128,7 +115,7 @@ function createBenchmarks({ store, baseUrl }) {
@@ -128,7 +115,7 @@ function createBenchmarks({ store, baseUrl }) {
/** Before reading a posted body: the link exists, is unused and unexpired. */
/** Before reading a posted body: the link exists, is unused and unexpired. */
async function checkLink(token) {
async function checkLink(token) {
if (!tokenOk(token)) usable(null);
if (!tokenOk(token)) usable(null);
- const link = await safe(() => db.prepare('SELECT used_at, expires_at FROM catalog_bench_links WHERE token_hash = ?').get(sha(token)), null);
+ const link = await db.prepare('SELECT used_at, expires_at FROM catalog_bench_links WHERE token_hash = ?').get(sha(token));
usable(link, store.now());
usable(link, store.now());
}
}
@@ -170,14 +157,12 @@ function createBenchmarks({ store, baseUrl }) {
@@ -170,14 +157,12 @@ function createBenchmarks({ store, baseUrl }) {
const args = [];
const args = [];
if (query.provider) { where += ' AND (v.slug = ? OR lower(b.provider_name) = lower(?))'; args.push(String(query.provider), String(query.provider)); }
if (query.provider) { where += ' AND (v.slug = ? OR lower(b.provider_name) = lower(?))'; args.push(String(query.provider), String(query.provider)); }
if (query.plan) { where += ' AND b.plan_id = ?'; args.push(String(query.plan)); }
if (query.plan) { where += ' AND b.plan_id = ?'; args.push(String(query.plan)); }
- return await safe(async () => {
- const rows = await db.prepare(`${SELECT} WHERE ${where} ORDER BY ${SORTS[sort]} LIMIT ${PAGE + 1} OFFSET ${offset}`).all(...args);
- return { benchmarks: rows.slice(0, PAGE).map(out), next_cursor: rows.length > PAGE ? String(offset + PAGE) : null };
- }, { benchmarks: [], next_cursor: null });
+ const rows = await db.prepare(`${SELECT} WHERE ${where} ORDER BY ${SORTS[sort]} LIMIT ${PAGE + 1} OFFSET ${offset}`).all(...args);
+ return { benchmarks: rows.slice(0, PAGE).map(out), next_cursor: rows.length > PAGE ? String(offset + PAGE) : null };
}
}
async function get(id, viewer = null) {
async function get(id, viewer = null) {
- const row = await safe(() => db.prepare(`${SELECT} WHERE b.id = ? AND b.status = 'active'`).get(String(id)), null);
+ const row = await db.prepare(`${SELECT} WHERE b.id = ? AND b.status = 'active'`).get(String(id));
if (!row) throw new ApiError(404, 'host.catalog.not_found', 'No such benchmark');
if (!row) throw new ApiError(404, 'host.catalog.not_found', 'No such benchmark');
return { ...out(row), mine: Boolean(viewer && viewer.subject && viewer.subject === row.submitted_by) };
return { ...out(row), mine: Boolean(viewer && viewer.subject && viewer.subject === row.submitted_by) };
}
}
@@ -185,14 +170,12 @@ function createBenchmarks({ store, baseUrl }) {
@@ -185,14 +170,12 @@ function createBenchmarks({ store, baseUrl }) {
/** The medians for a provider or a plan (a provider page's and a plan page's line). */
/** The medians for a provider or a plan (a provider page's and a plan page's line). */
async function summary({ providerId, planId }) {
async function summary({ providerId, planId }) {
const [col, val] = planId ? ['b.plan_id', planId] : ['b.provider_id', providerId];
const [col, val] = planId ? ['b.plan_id', planId] : ['b.provider_id', providerId];
- return await safe(async () => {
- const r = await db.prepare(`SELECT COUNT(*) AS n, percentile_cont(0.5) WITHIN GROUP (ORDER BY b.gb_single) AS gb_single, percentile_cont(0.5) WITHIN GROUP (ORDER BY b.gb_multi) AS gb_multi,
- percentile_cont(0.5) WITHIN GROUP (ORDER BY b.disk_4k_iops) AS disk_4k_iops, MAX(b.gb_version) AS gb_version, MAX(b.created_at) AS latest
- FROM catalog_benchmarks b WHERE ${col} = ? AND b.status = 'active'`).get(val);
- const n = Number(r.n) || 0;
- return n ? { count: n, gb_single: r.gb_single == null ? null : Math.round(Number(r.gb_single)), gb_multi: r.gb_multi == null ? null : Math.round(Number(r.gb_multi)),
- disk_4k_iops: r.disk_4k_iops == null ? null : Math.round(Number(r.disk_4k_iops)), gb_version: r.gb_version == null ? null : Number(r.gb_version), latest: new Date(Number(r.latest)).toISOString() } : { count: 0 };
- }, { count: 0 });
+ const r = await db.prepare(`SELECT COUNT(*) AS n, percentile_cont(0.5) WITHIN GROUP (ORDER BY b.gb_single) AS gb_single, percentile_cont(0.5) WITHIN GROUP (ORDER BY b.gb_multi) AS gb_multi,
+ percentile_cont(0.5) WITHIN GROUP (ORDER BY b.disk_4k_iops) AS disk_4k_iops, MAX(b.gb_version) AS gb_version, MAX(b.created_at) AS latest
+ FROM catalog_benchmarks b WHERE ${col} = ? AND b.status = 'active'`).get(val);
+ const n = Number(r.n) || 0;
+ return n ? { count: n, gb_single: r.gb_single == null ? null : Math.round(Number(r.gb_single)), gb_multi: r.gb_multi == null ? null : Math.round(Number(r.gb_multi)),
+ disk_4k_iops: r.disk_4k_iops == null ? null : Math.round(Number(r.disk_4k_iops)), gb_version: r.gb_version == null ? null : Number(r.gb_version), latest: new Date(Number(r.latest)).toISOString() } : { count: 0 };
}
}
/** The person who submitted a result, or staff, takes it down. */
/** The person who submitted a result, or staff, takes it down. */
@@ -203,7 +186,7 @@ function createBenchmarks({ store, baseUrl }) {
@@ -203,7 +186,7 @@ function createBenchmarks({ store, baseUrl }) {
await db.prepare("UPDATE catalog_benchmarks SET status = 'hidden', updated_at = ? WHERE id = ?").run(store.now(), String(id));
await db.prepare("UPDATE catalog_benchmarks SET status = 'hidden', updated_at = ? WHERE id = ?").run(store.now(), String(id));
}
}
- return { createLink, checkLink, submit, list, get, summary, remove, ready, SORTS: Object.keys(SORTS) };
+ return { createLink, checkLink, submit, list, get, summary, remove, SORTS: Object.keys(SORTS) };
}
}
module.exports = { parseYabs, createBenchmarks, YABS_COMMAND, LINK_TTL_MS, LINKS_PER_DAY };
module.exports = { parseYabs, createBenchmarks, YABS_COMMAND, LINK_TTL_MS, LINKS_PER_DAY };
@@ -2,9 +2,6 @@
@@ -2,9 +2,6 @@
/**
/**
* Domain prices in the catalog (migrations/0011_catalog_domains.sql, server/catalog/domains.js): one row per registrar
* Domain prices in the catalog (migrations/0011_catalog_domains.sql, server/catalog/domains.js): one row per registrar
* and TLD, applied from Sources items; the TLD table, a TLD's page and the cheapest price for a list of endings.
* and TLD, applied from Sources items; the TLD table, a TLD's page and the cheapest price for a list of endings.
- *
- * Until 0011 has applied (a held contract migration holds every later one) the tables do not exist: reads answer
- * empty and `ready()` is false, so pages leave the price sections out rather than fail.
*/
*/
const { newId } = require('../ids');
const { newId } = require('../ids');
const { ApiError } = require('../http/errors');
const { ApiError } = require('../http/errors');
@@ -28,24 +25,12 @@ const KINDS = {
@@ -28,24 +25,12 @@ const KINDS = {
'second-level': "position('.' in p.tld) > 0",
'second-level': "position('.' in p.tld) > 0",
internationalized: "position('.' in p.tld) = 0 AND p.tld LIKE 'xn--%'",
internationalized: "position('.' in p.tld) = 0 AND p.tld LIKE 'xn--%'",
};
};
-const missingTable = (err) => err && err.code === '42P01';
const n = (v) => (v == null ? null : Number(v));
const n = (v) => (v == null ? null : Number(v));
const iso = (ms) => (ms == null ? null : new Date(Number(ms)).toISOString());
const iso = (ms) => (ms == null ? null : new Date(Number(ms)).toISOString());
function createDomainStore({ store, announce = () => {} }) {
function createDomainStore({ store, announce = () => {} }) {
const db = store.db;
const db = store.db;
let known = { at: 0, set: new Set() };
let known = { at: 0, set: new Set() };
- let isReady = null;
-
- async function ready() {
- if (isReady) return true;
- const r = await db.prepare("SELECT to_regclass('catalog_tld_prices') AS t").get();
- isReady = Boolean(r && r.t);
- return isReady;
- }
- async function safe(fn, fallback) {
- try { return await fn(); } catch (err) { if (missingTable(err)) { isReady = false; return fallback; } throw err; }
- }
async function apply(item, t = db) {
async function apply(item, t = db) {
const now = store.now();
const now = store.now();
@@ -115,24 +100,20 @@ function createDomainStore({ store, announce = () => {} }) {
@@ -115,24 +100,20 @@ function createDomainStore({ store, announce = () => {} }) {
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(String(query.registrar))) invalid('Invalid registrar');
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(String(query.registrar))) invalid('Invalid registrar');
where += ' AND v.slug = ?'; args.push(query.registrar);
where += ' AND v.slug = ?'; args.push(query.registrar);
}
}
- return await safe(async () => {
- const rows = await db.prepare(`SELECT p.tld, 'USD' AS currency, MIN(p.register) AS register, MIN(p.renew) AS renew, MIN(p.transfer) AS transfer, MIN(${FIVE_YEAR}) AS five_year, COUNT(*) AS registrars, MAX(p.checked_at) AS checked_at
- FROM catalog_tld_prices p JOIN catalog_providers v ON v.id = p.provider_id WHERE ${where} GROUP BY p.tld ORDER BY ${SORTS[sort]} LIMIT ${PAGE + 1} OFFSET ${offset}`).all(...args);
- return { tlds: rows.slice(0, PAGE).map(tldRow), next_cursor: rows.length > PAGE ? String(offset + PAGE) : null };
- }, { tlds: [], next_cursor: null });
+ const rows = await db.prepare(`SELECT p.tld, 'USD' AS currency, MIN(p.register) AS register, MIN(p.renew) AS renew, MIN(p.transfer) AS transfer, MIN(${FIVE_YEAR}) AS five_year, COUNT(*) AS registrars, MAX(p.checked_at) AS checked_at
+ FROM catalog_tld_prices p JOIN catalog_providers v ON v.id = p.provider_id WHERE ${where} GROUP BY p.tld ORDER BY ${SORTS[sort]} LIMIT ${PAGE + 1} OFFSET ${offset}`).all(...args);
+ return { tlds: rows.slice(0, PAGE).map(tldRow), next_cursor: rows.length > PAGE ? String(offset + PAGE) : null };
}
}
/** One TLD: every registrar's price (cheapest first year first) and the latest price changes. */
/** One TLD: every registrar's price (cheapest first year first) and the latest price changes. */
async function tld(input) {
async function tld(input) {
const t = domains.normalizeTld(String(input || ''));
const t = domains.normalizeTld(String(input || ''));
if (!t) throw new ApiError(404, 'host.catalog.not_found', 'Not a domain ending');
if (!t) throw new ApiError(404, 'host.catalog.not_found', 'Not a domain ending');
- const data = await safe(async () => {
- const rows = await db.prepare(`SELECT p.*, ${FIVE_YEAR} AS five_year, v.slug AS provider_slug, v.name AS provider_name, v.homepage_url, v.affiliate_url, v.affiliate_note
- FROM catalog_tld_prices p JOIN catalog_providers v ON v.id = p.provider_id WHERE ${ACTIVE} AND p.tld = ? ORDER BY p.register ASC NULLS LAST, v.name, p.id`).all(t);
- const history = rows.length ? await db.prepare(`SELECT h.*, v.name AS provider_name FROM catalog_tld_price_changes h JOIN catalog_tld_prices p ON p.id = h.price_id JOIN catalog_providers v ON v.id = p.provider_id
- WHERE p.tld = ? AND ${ACTIVE} ORDER BY h.observed_at DESC LIMIT 20`).all(t) : [];
- return { rows, history };
- }, { rows: [], history: [] });
+ const rows = await db.prepare(`SELECT p.*, ${FIVE_YEAR} AS five_year, v.slug AS provider_slug, v.name AS provider_name, v.homepage_url, v.affiliate_url, v.affiliate_note
+ FROM catalog_tld_prices p JOIN catalog_providers v ON v.id = p.provider_id WHERE ${ACTIVE} AND p.tld = ? ORDER BY p.register ASC NULLS LAST, v.name, p.id`).all(t);
+ const history = rows.length ? await db.prepare(`SELECT h.*, v.name AS provider_name FROM catalog_tld_price_changes h JOIN catalog_tld_prices p ON p.id = h.price_id JOIN catalog_providers v ON v.id = p.provider_id
+ WHERE p.tld = ? AND ${ACTIVE} ORDER BY h.observed_at DESC LIMIT 20`).all(t) : [];
+ const data = { rows, history };
if (!data.rows.length) throw new ApiError(404, 'host.catalog.not_found', 'No registrar price is listed for this ending');
if (!data.rows.length) throw new ApiError(404, 'host.catalog.not_found', 'No registrar price is listed for this ending');
const prices = data.rows.map((r) => ({
const prices = data.rows.map((r) => ({
registrar: r.provider_name, registrar_slug: r.provider_slug, homepage_url: r.homepage_url, affiliate_url: r.affiliate_url, affiliate_note: r.affiliate_note,
registrar: r.provider_name, registrar_slug: r.provider_slug, homepage_url: r.homepage_url, affiliate_url: r.affiliate_url, affiliate_note: r.affiliate_note,
@@ -154,8 +135,8 @@ function createDomainStore({ store, announce = () => {} }) {
@@ -154,8 +135,8 @@ function createDomainStore({ store, announce = () => {} }) {
async function cheapest(tlds) {
async function cheapest(tlds) {
const list = [...new Set(tlds.map((x) => domains.normalizeTld(x)).filter(Boolean))].slice(0, 30);
const list = [...new Set(tlds.map((x) => domains.normalizeTld(x)).filter(Boolean))].slice(0, 30);
if (!list.length) return [];
if (!list.length) return [];
- const rows = await safe(() => db.prepare(`SELECT p.tld, 'USD' AS currency, MIN(p.register) AS register, MIN(p.renew) AS renew, MIN(p.transfer) AS transfer, MIN(${FIVE_YEAR}) AS five_year, COUNT(*) AS registrars, MAX(p.checked_at) AS checked_at
- FROM catalog_tld_prices p JOIN catalog_providers v ON v.id = p.provider_id WHERE ${ACTIVE} AND p.currency = 'USD' AND p.tld IN (${list.map(() => '?').join(', ')}) GROUP BY p.tld`).all(...list), []);
+ const rows = await db.prepare(`SELECT p.tld, 'USD' AS currency, MIN(p.register) AS register, MIN(p.renew) AS renew, MIN(p.transfer) AS transfer, MIN(${FIVE_YEAR}) AS five_year, COUNT(*) AS registrars, MAX(p.checked_at) AS checked_at
+ FROM catalog_tld_prices p JOIN catalog_providers v ON v.id = p.provider_id WHERE ${ACTIVE} AND p.currency = 'USD' AND p.tld IN (${list.map(() => '?').join(', ')}) GROUP BY p.tld`).all(...list);
const by = new Map(rows.map((r) => [r.tld, tldRow(r)]));
const by = new Map(rows.map((r) => [r.tld, tldRow(r)]));
return list.filter((t) => by.has(t)).map((t) => by.get(t));
return list.filter((t) => by.has(t)).map((t) => by.get(t));
}
}
@@ -163,29 +144,27 @@ function createDomainStore({ store, announce = () => {} }) {
@@ -163,29 +144,27 @@ function createDomainStore({ store, announce = () => {} }) {
/** Every TLD with a listed price (for reading what a person typed), refreshed every five minutes. */
/** Every TLD with a listed price (for reading what a person typed), refreshed every five minutes. */
async function knownTlds() {
async function knownTlds() {
if (store.now() - known.at < 5 * 60_000) return known.set;
if (store.now() - known.at < 5 * 60_000) return known.set;
- const rows = await safe(() => db.prepare(`SELECT DISTINCT p.tld FROM catalog_tld_prices p JOIN catalog_providers v ON v.id = p.provider_id WHERE ${ACTIVE}`).all(), []);
+ const rows = await db.prepare(`SELECT DISTINCT p.tld FROM catalog_tld_prices p JOIN catalog_providers v ON v.id = p.provider_id WHERE ${ACTIVE}`).all();
known = { at: store.now(), set: new Set(rows.map((r) => r.tld)) };
known = { at: store.now(), set: new Set(rows.map((r) => r.tld)) };
return known.set;
return known.set;
}
}
async function stats() {
async function stats() {
- return await safe(async () => {
- const r = await db.prepare(`SELECT COUNT(DISTINCT p.tld) AS tlds, MAX(p.checked_at) AS last FROM catalog_tld_prices p JOIN catalog_providers v ON v.id = p.provider_id WHERE ${ACTIVE}`).get();
- const names = await db.prepare(`SELECT DISTINCT v.name FROM catalog_tld_prices p JOIN catalog_providers v ON v.id = p.provider_id WHERE ${ACTIVE} ORDER BY v.name`).all();
- return { tlds: Number(r.tlds) || 0, registrars: names.map((x) => x.name), lastChecked: r.last == null ? null : Number(r.last) };
- }, { tlds: 0, registrars: [], lastChecked: null });
+ const r = await db.prepare(`SELECT COUNT(DISTINCT p.tld) AS tlds, MAX(p.checked_at) AS last FROM catalog_tld_prices p JOIN catalog_providers v ON v.id = p.provider_id WHERE ${ACTIVE}`).get();
+ const names = await db.prepare(`SELECT DISTINCT v.name FROM catalog_tld_prices p JOIN catalog_providers v ON v.id = p.provider_id WHERE ${ACTIVE} ORDER BY v.name`).all();
+ return { tlds: Number(r.tlds) || 0, registrars: names.map((x) => x.name), lastChecked: r.last == null ? null : Number(r.last) };
}
}
async function sitemap() {
async function sitemap() {
- return await safe(() => db.prepare(`SELECT p.tld, MAX(p.checked_at) AS checked_at FROM catalog_tld_prices p JOIN catalog_providers v ON v.id = p.provider_id WHERE ${ACTIVE} GROUP BY p.tld ORDER BY p.tld`).all(), []);
+ return db.prepare(`SELECT p.tld, MAX(p.checked_at) AS checked_at FROM catalog_tld_prices p JOIN catalog_providers v ON v.id = p.provider_id WHERE ${ACTIVE} GROUP BY p.tld ORDER BY p.tld`).all();
}
}
/** How many TLDs each registrar lists (a provider page's domain line). */
/** How many TLDs each registrar lists (a provider page's domain line). */
async function countFor(providerId) {
async function countFor(providerId) {
- return await safe(async () => Number((await db.prepare("SELECT COUNT(*) AS n FROM catalog_tld_prices WHERE provider_id = ? AND status = 'active'").get(providerId)).n) || 0, 0);
+ return Number((await db.prepare("SELECT COUNT(*) AS n FROM catalog_tld_prices WHERE provider_id = ? AND status = 'active'").get(providerId)).n) || 0;
}
}
- return { apply, list, tld, cheapest, knownTlds, stats, sitemap, countFor, ready, SORTS: Object.keys(SORTS), KINDS: Object.keys(KINDS) };
+ return { apply, list, tld, cheapest, knownTlds, stats, sitemap, countFor, SORTS: Object.keys(SORTS), KINDS: Object.keys(KINDS) };
}
}
module.exports = { createDomainStore };
module.exports = { createDomainStore };
@@ -89,9 +89,6 @@ function tldDocument(row) {
@@ -89,9 +89,6 @@ function tldDocument(row) {
};
};
}
}
-// Before migration 0011 applies (a held contract holds it) there is no price table: no ending to send yet.
-const orNone = (p) => p.catch((err) => { if (err && err.code === '42P01') return []; throw err; });
-
function createCatalogSearch({ config, store, outbox, log = console }) {
function createCatalogSearch({ config, store, outbox, log = console }) {
const db = store.db;
const db = store.db;
const feed = createSearchFeed({
const feed = createSearchFeed({
@@ -119,7 +116,7 @@ function createCatalogSearch({ config, store, outbox, log = console }) {
@@ -119,7 +116,7 @@ function createCatalogSearch({ config, store, outbox, log = console }) {
page: (row) => `/catalog/domains/${encodeURIComponent(row.tld)}`,
page: (row) => `/catalog/domains/${encodeURIComponent(row.tld)}`,
document: tldDocument,
document: tldDocument,
// Every ending any registrar has listed: its active prices aggregated (none active: unlisted, a tombstone).
// Every ending any registrar has listed: its active prices aggregated (none active: unlisted, a tombstone).
- rows: (after, limit) => orNone(db.many(`SELECT 'tld-' || p.tld AS id, p.tld,
+ rows: (after, limit) => db.many(`SELECT 'tld-' || p.tld AS id, p.tld,
MIN(p.register) FILTER (WHERE p.status = 'active' AND v.status = 'active') AS register,
MIN(p.register) FILTER (WHERE p.status = 'active' AND v.status = 'active') AS register,
MIN(p.renew) FILTER (WHERE p.status = 'active' AND v.status = 'active') AS renew,
MIN(p.renew) FILTER (WHERE p.status = 'active' AND v.status = 'active') AS renew,
MIN(COALESCE(p.register, p.renew) + 4 * COALESCE(p.renew, p.register)) FILTER (WHERE p.status = 'active' AND v.status = 'active') AS five_year,
MIN(COALESCE(p.register, p.renew) + 4 * COALESCE(p.renew, p.register)) FILTER (WHERE p.status = 'active' AND v.status = 'active') AS five_year,
@@ -128,8 +125,8 @@ function createCatalogSearch({ config, store, outbox, log = console }) {
@@ -128,8 +125,8 @@ function createCatalogSearch({ config, store, outbox, log = console }) {
(array_agg(p.source_url ORDER BY p.register NULLS LAST))[1] AS source_url,
(array_agg(p.source_url ORDER BY p.register NULLS LAST))[1] AS source_url,
(array_agg(v.name ORDER BY p.register NULLS LAST))[1] AS registrar
(array_agg(v.name ORDER BY p.register NULLS LAST))[1] AS registrar
FROM catalog_tld_prices p JOIN catalog_providers v ON v.id = p.provider_id
FROM catalog_tld_prices p JOIN catalog_providers v ON v.id = p.provider_id
- GROUP BY p.tld HAVING 'tld-' || p.tld > $1 ORDER BY 1 LIMIT $2`, [after, limit])),
- exists: async (ids) => (await orNone(db.many("SELECT DISTINCT 'tld-' || tld AS id FROM catalog_tld_prices WHERE 'tld-' || tld = ANY($1)", [ids]))).map((r) => r.id),
+ GROUP BY p.tld HAVING 'tld-' || p.tld > $1 ORDER BY 1 LIMIT $2`, [after, limit]),
+ exists: async (ids) => (await db.many("SELECT DISTINCT 'tld-' || tld AS id FROM catalog_tld_prices WHERE 'tld-' || tld = ANY($1)", [ids])).map((r) => r.id),
},
},
},
},
});
});
@@ -193,14 +193,7 @@ function createCatalog({ store, community = null, log = console, indexnow = null
@@ -193,14 +193,7 @@ function createCatalog({ store, community = null, log = console, indexnow = null
}
}
const offset = query.cursor == null ? 0 : Number(query.cursor);
const offset = query.cursor == null ? 0 : Number(query.cursor);
if (!Number.isSafeInteger(offset) || offset < 0) invalid('Invalid cursor');
if (!Number.isSafeInteger(offset) || offset < 0) invalid('Invalid cursor');
- let rows;
- try {
- rows = await db.prepare(`SELECT * FROM catalog_offers WHERE ${where} ORDER BY ${OFFER_SORTS[sort]} LIMIT 51 OFFSET ${offset}`).all(...args);
- } catch (err) {
- // Before migration 0009 has applied (a held contract migration holds it), there are no offers yet.
- if (err && err.code === '42P01') return { offers: [], next_cursor: null };
- throw err;
- }
+ const rows = await db.prepare(`SELECT * FROM catalog_offers WHERE ${where} ORDER BY ${OFFER_SORTS[sort]} LIMIT 51 OFFSET ${offset}`).all(...args);
return { offers: rows.slice(0, 50).map(offerOut), next_cursor: rows.length > 50 ? String(offset + 50) : null };
return { offers: rows.slice(0, 50).map(offerOut), next_cursor: rows.length > 50 ? String(offset + 50) : null };
}
}
const planOut = (p) => ({ ...p, affiliate_rel: p.affiliate_url ? 'sponsored nofollow noopener' : null, affiliate_disclosure: p.affiliate_url ? 'OpenVibe may earn a commission from links marked as affiliate links; it never changes the order or the prices shown.' : null, price_per_vcpu: p.vcpu > 0 ? Number(p.price_monthly) / Number(p.vcpu) : null, price_per_gb_ram: p.ram_mb > 0 ? Number(p.price_monthly) * 1024 / Number(p.ram_mb) : null, price_per_gb_disk: p.disk_gb > 0 ? Number(p.price_monthly) / Number(p.disk_gb) : null, checked_at: new Date(Number(p.checked_at)).toISOString() });
const planOut = (p) => ({ ...p, affiliate_rel: p.affiliate_url ? 'sponsored nofollow noopener' : null, affiliate_disclosure: p.affiliate_url ? 'OpenVibe may earn a commission from links marked as affiliate links; it never changes the order or the prices shown.' : null, price_per_vcpu: p.vcpu > 0 ? Number(p.price_monthly) / Number(p.vcpu) : null, price_per_gb_ram: p.ram_mb > 0 ? Number(p.price_monthly) * 1024 / Number(p.ram_mb) : null, price_per_gb_disk: p.disk_gb > 0 ? Number(p.price_monthly) / Number(p.disk_gb) : null, checked_at: new Date(Number(p.checked_at)).toISOString() });
@@ -9,9 +9,8 @@ const { createDiscussionRefs } = require('openvibe-publishing/discussion');
@@ -9,9 +9,8 @@ const { createDiscussionRefs } = require('openvibe-publishing/discussion');
const MIGRATIONS = path.join(__dirname, '..', 'migrations');
const MIGRATIONS = path.join(__dirname, '..', 'migrations');
const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');
const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');
-// catalog_offers (0009) is not required yet: the 0008 contract holds every later migration until 2026-10-19 00:37 UTC
-// (sdk/db keeps the order), and the offers page shows none until then. Required again once 0009 has applied.
-const CHARTER_TABLES = ['host_catalog_discussion_refs', 'catalog_providers', 'catalog_plans', 'catalog_price_changes', 'catalog_promos', 'catalog_ingest_cursor'];
+const CHARTER_TABLES = ['host_catalog_discussion_refs', 'catalog_providers', 'catalog_plans', 'catalog_price_changes', 'catalog_promos', 'catalog_ingest_cursor',
+ 'catalog_offers', 'catalog_tld_prices', 'catalog_tld_price_changes', 'catalog_bench_links', 'catalog_benchmarks'];
/**
/**
* The serving handle (ADR-035): DATABASE_URL through PgBouncer; in development without it, an embedded PGlite database
* The serving handle (ADR-035): DATABASE_URL through PgBouncer; in development without it, an embedded PGlite database
@@ -4,8 +4,7 @@
@@ -4,8 +4,7 @@
* price list from Sources becomes one price per TLD (Handshake names left out, "2,060.25" read, a change kept as history,
* price list from Sources becomes one price per TLD (Handshake names left out, "2,060.25" read, a change kept as history,
* a TLD no longer sold gone); the TLD table sorts popular first, by price or over five years; a TLD's page lists every
* a TLD no longer sold gone); the TLD table sorts popular first, by price or over five years; a TLD's page lists every
* registrar; a name check reads what was typed, asks the registry over RDAP (cached, refused redirects, unknown when the
* registrar; a name check reads what was typed, asks the registry over RDAP (cached, refused redirects, unknown when the
- * ending has no RDAP) and prices the same name at the popular endings; without the tables (a held migration) the pages
- * still answer.
+ * ending has no RDAP) and prices the same name at the popular endings; readiness waits for the tables.
*/
*/
const assert = require('assert');
const assert = require('assert');
const { boot, check, done } = require('./helpers/boot');
const { boot, check, done } = require('./helpers/boot');
@@ -175,23 +174,9 @@ function fakeRegistry() {
@@ -175,23 +174,9 @@ function fakeRegistry() {
assert.deepStrictEqual([empty.status, empty.headers.location], [303, '/catalog/domains']);
assert.deepStrictEqual([empty.status, empty.headers.location], [303, '/catalog/domains']);
});
});
- await check('without the domain tables (a held migration) the pages still answer and a name can be checked', async () => {
- // The app role cannot drop tables on PostgreSQL: every statement naming them answers as PostgreSQL does when they are missing.
- const db = t.ctx.store.db;
- const prepare = db.prepare;
- const missing = () => Promise.reject(Object.assign(new Error('relation "catalog_tld_prices" does not exist'), { code: '42P01' }));
- db.prepare = (sql) => (/catalog_tld_/.test(sql) ? { all: missing, get: missing, run: missing } : prepare.call(db, sql));
- const index = await t.api('GET', '/catalog/domains');
- assert.strictEqual(index.status, 200);
- assert.match(index.text, /Is your name free\?/);
- assert.doesNotMatch(index.text, /<table/, 'no empty price table');
- assert.deepStrictEqual((await t.api('GET', '/api/v1/catalog/domains')).json(), { tlds: [], next_cursor: null });
- assert.strictEqual((await t.api('GET', '/catalog/domains/dev')).status, 404);
- assert.strictEqual((await t.api('GET', '/')).status, 200);
- assert.strictEqual((await t.api('GET', '/sitemap.xml')).status, 200);
- assert.strictEqual((await t.api('GET', '/catalog/providers/porkbun')).status, 200);
- assert.strictEqual((await t.api('GET', '/api/v1/catalog/domains/check?name=taken.com')).json().check.status, 'registered');
- db.prepare = prepare;
+ await check('readiness waits for the domain tables (a release never answers without them)', async () => {
+ const { CHARTER_TABLES } = require('../server/db');
+ for (const table of ['catalog_tld_prices', 'catalog_tld_price_changes']) assert.ok(CHARTER_TABLES.includes(table), table);
});
});
} finally { await t.close(); }
} finally { await t.close(); }
done();
done();
@@ -94,16 +94,8 @@ const item = (key, title, { published = '2026-09-20T10:00:00Z', summary = null,
@@ -94,16 +94,8 @@ const item = (key, title, { published = '2026-09-20T10:00:00Z', summary = null,
assert.match(front.text, /Latest hosting offers/);
assert.match(front.text, /Latest hosting offers/);
assert.match(front.text, /href="\/catalog\/offers"/);
assert.match(front.text, /href="\/catalog\/offers"/);
});
});
- await check('before migration 0009 applies (a held contract holds it), offers answer empty and readiness does not need the table', async () => {
- const { createCatalog } = require('../server/catalog/store');
- const missing = Object.assign(new Error('relation "catalog_offers" does not exist'), { code: '42P01' });
- const db = { prepare: () => ({ all: async () => { throw missing; }, get: async () => null, run: async () => ({ changes: 0 }) }) };
- const early = createCatalog({ store: { db, now: () => NOW } });
- assert.deepStrictEqual(await early.offers({}), { offers: [], next_cursor: null });
- assert.ok(!require('../server/db').CHARTER_TABLES.includes('catalog_offers'), 'readiness does not wait for the table');
- const other = Object.assign(new Error('boom'), { code: '57014' });
- const broken = createCatalog({ store: { db: { prepare: () => ({ all: async () => { throw other; } }) }, now: () => NOW } });
- await assert.rejects(broken.offers({}), /boom/, 'any other error still fails');
+ await check('readiness waits for the offers table (a release never answers without it)', async () => {
+ assert.ok(require('../server/db').CHARTER_TABLES.includes('catalog_offers'));
});
});
} finally { await t.close(); }
} finally { await t.close(); }
done();
done();