From ab4b80470f3de9707a32e232675f0d00a5c571f4 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 20 Oct 2025 12:52:24 -0400 Subject: [PATCH] Optimize admin list publish flow --- js/adminListStore.js | 144 ++++++++++++++++++++++++++++---- tests/admin-list-store.test.mjs | 65 +++++++++++++- 2 files changed, 190 insertions(+), 19 deletions(-) diff --git a/js/adminListStore.js b/js/adminListStore.js index 153aeab4..febf4120 100644 --- a/js/adminListStore.js +++ b/js/adminListStore.js @@ -16,7 +16,7 @@ import { buildAdminListEvent, ADMIN_LIST_IDENTIFIERS, } from "./nostrEventSchemas.js"; -import { publishEventToRelays } from "./nostrPublish.js"; +import { publishEventToRelay } from "./nostrPublish.js"; const LEGACY_STORAGE_KEYS = { editors: "bitvid_admin_editors", @@ -304,7 +304,16 @@ function ensureNostrReady() { ); } - const relays = Array.isArray(nostrClient.relays) ? nostrClient.relays : []; + const writeRelays = + Array.isArray(nostrClient.writeRelays) && nostrClient.writeRelays.length + ? nostrClient.writeRelays + : []; + const relays = writeRelays.length + ? writeRelays + : Array.isArray(nostrClient.relays) + ? nostrClient.relays + : []; + if (!relays.length) { throw createError("nostr-unavailable", "No Nostr relays configured."); } @@ -533,6 +542,94 @@ function buildListEvent(listKey, npubs, actorHex) { }); } +function publishListWithFirstAcceptance(pool, relays, event, options = {}) { + const publishRelay = + typeof options.publishRelay === "function" + ? options.publishRelay + : publishEventToRelay; + const listKey = typeof options.listKey === "string" ? options.listKey : ""; + const publishOptions = + options && typeof options.publishOptions === "object" + ? options.publishOptions + : undefined; + + const relayPromises = Array.isArray(relays) + ? relays.map((url) => publishRelay(pool, url, event, publishOptions)) + : []; + const resultsBuffer = new Array(relayPromises.length); + + let resolved = false; + let remaining = relayPromises.length; + + const firstAcceptance = new Promise((resolve, reject) => { + if (!relayPromises.length) { + reject( + createError( + "publish-failed", + listKey + ? `Failed to publish ${listKey} list to any relay.` + : "Failed to publish admin list to any relay.", + ) + ); + return; + } + + relayPromises.forEach((promise, index) => { + promise + .then((result) => { + resultsBuffer[index] = result; + if (result?.success && !resolved) { + resolved = true; + resolve(result); + } + }) + .catch((error) => { + resultsBuffer[index] = { + url: Array.isArray(relays) ? relays[index] : "", + success: false, + error, + }; + }) + .finally(() => { + remaining -= 1; + if (!resolved && remaining === 0) { + reject( + createError( + "publish-failed", + listKey + ? `Failed to publish ${listKey} list to any relay.` + : "Failed to publish admin list to any relay.", + ) + ); + } + }); + }); + }); + + const allResults = Promise.allSettled(relayPromises).then((entries) => { + for (let index = 0; index < entries.length; index += 1) { + const entry = entries[index]; + const fallbackUrl = Array.isArray(relays) ? relays[index] : ""; + const result = + entry?.status === "fulfilled" + ? entry.value + : { + url: fallbackUrl, + success: false, + error: entry?.reason || new Error("publish failed"), + }; + + if (typeof resultsBuffer[index] === "undefined") { + resultsBuffer[index] = result; + } + } + + return resultsBuffer; + }); + + return { firstAcceptance, allResults, resultsBuffer }; +} + async function persistNostrState(actorNpub, updates = {}) { ensureNostrReady(); const permissionResult = await requestDefaultExtensionPermissions(); @@ -620,29 +717,41 @@ async function persistNostrState(actorNpub, updates = {}) { } const relays = ensureNostrReady(); - const publishResults = await publishEventToRelays( + const { firstAcceptance, allResults } = publishListWithFirstAcceptance( nostrClient.pool, relays, signedEvent, + { listKey }, ); - if (isDevMode) { - publishResults - .filter((result) => !result.success) - .forEach((result) => { - userLogger.warn( - `[adminListStore] Publish failed for ${listKey} on ${result.url}:`, - result.error, - ); - }); + const logSettledResults = allResults.then((results) => { + if (isDevMode) { + results + .filter((result) => !result?.success) + .forEach((result) => { + userLogger.warn( + `[adminListStore] Publish failed for ${listKey} on ${result.url}:`, + result.error, + ); + }); + } + + return results; + }); + + try { + await firstAcceptance; + } catch (error) { + await logSettledResults.catch(() => {}); + throw error; } - if (!publishResults.some((result) => result.success)) { - throw createError( - "publish-failed", - `Failed to publish ${listKey} list to any relay.` + logSettledResults.catch((loggingError) => { + devLogger.warn( + `[adminListStore] Failed to summarize publish results for ${listKey}:`, + loggingError, ); - } + }); clearLegacyStorageFor(listKey); } @@ -682,4 +791,5 @@ export async function persistAdminState(actorNpub, updates) { export const __adminListStoreTestHooks = Object.freeze({ extractNpubsFromEvent, normalizeParticipantTagValue, + publishListWithFirstAcceptance, }); diff --git a/tests/admin-list-store.test.mjs b/tests/admin-list-store.test.mjs index 33ebf951..d0197766 100644 --- a/tests/admin-list-store.test.mjs +++ b/tests/admin-list-store.test.mjs @@ -37,8 +37,11 @@ globalThis.window.NostrTools.nip19 = mockNip19; const { __adminListStoreTestHooks } = await import("../js/adminListStore.js"); -const { extractNpubsFromEvent, normalizeParticipantTagValue } = - __adminListStoreTestHooks; +const { + extractNpubsFromEvent, + normalizeParticipantTagValue, + publishListWithFirstAcceptance, +} = __adminListStoreTestHooks; const sampleHex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; const sampleNpub = "npub1existingparticipant"; @@ -86,4 +89,62 @@ assert.equal( "should preserve hex when nip19 helpers are unavailable" ); +const relays = [ + "wss://fast.example", + "wss://slow.example", +]; + +for (const listKey of ["whitelist", "blacklist"]) { + const start = Date.now(); + const publishBehavior = publishListWithFirstAcceptance( + {}, + relays, + { id: `event-${listKey}` }, + { + listKey, + publishRelay: (_pool, url) => + new Promise((resolve) => { + const isFast = url === relays[0]; + const delayMs = isFast ? 5 : 200; + setTimeout(() => { + resolve({ + url, + success: isFast, + error: isFast ? null : new Error("relay rejected"), + }); + }, delayMs); + }), + }, + ); + + const acceptanceResult = await publishBehavior.firstAcceptance; + const elapsed = Date.now() - start; + + assert.equal( + acceptanceResult.url, + relays[0], + `should resolve using the fast relay for ${listKey}`, + ); + + assert.ok( + elapsed < 150, + `expected ${listKey} publish to resolve before slow relay timeout (elapsed ${elapsed}ms)`, + ); + + const settledResults = await publishBehavior.allResults; + + assert.equal( + settledResults.length, + relays.length, + `should collect all publish results for ${listKey}`, + ); + + const acceptedCount = settledResults.filter((entry) => entry.success).length; + assert.equal( + acceptedCount, + 1, + `should mark exactly one relay as successful for ${listKey}`, + ); +} + console.log("admin-list-store tests passed");