From d693bf55aadd69c9466121c9ef86914ed418acd4 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 14 Jun 2026 16:15:06 -0400 Subject: [PATCH] refactor(profiles): batch profile fetch through SubscriptionManager (P4) performProfileFetch now issues ONE deduped, health-gated list() across all relays via the L1 SubscriptionManager (the pool merges per-relay results), replacing the manual per-relay fan-out. Newest-per-author selection unchanged; legacy per-relay path retained as the manager-less fallback. Spec correction: tests/app-batch-fetch-profiles.test.mjs asserted one query PER relay (implementation detail); updated to assert one BATCHED query across all relays. All behavioral assertions (cached hydration, newest profile rendered, failing relay tolerated) unchanged; added a relays-targeting assertion. Documented in TEST_INTEGRITY.md (SCN-profiles-batched). Co-Authored-By: Claude Opus 4.8 --- TEST_INTEGRITY.md | 39 ++++++++++ js/services/profileMetadataService.js | 99 +++++++++++++------------ tests/app-batch-fetch-profiles.test.mjs | 15 +++- 3 files changed, 104 insertions(+), 49 deletions(-) create mode 100644 TEST_INTEGRITY.md diff --git a/TEST_INTEGRITY.md b/TEST_INTEGRITY.md new file mode 100644 index 00000000..6013e3cd --- /dev/null +++ b/TEST_INTEGRITY.md @@ -0,0 +1,39 @@ +# Test Integrity Notes + +Machine-readable record of test-expectation changes made under the Spec +Correction Protocol (see CLAUDE.md → Dark Factory rules). Each entry justifies +why a previous expectation was wrong relative to intended behavior and confirms +the replacement is equally strict (or stricter). + +--- + +```yaml +test_integrity_note: + date: 2026-06-14 + file: tests/app-batch-fetch-profiles.test.mjs + change_type: ["spec_correction"] + scenarios: + - id: SCN-profiles-batched + given: "Multiple relays configured (one fast, one failing) and uncached, valid pubkeys to fetch" + when: "batchFetchProfiles runs after profile fetches were migrated to the L1 SubscriptionManager" + then: "Profiles are fetched in ONE batched query across all relays (not one query per relay); the fast relay's newest profile still renders, cached profiles hydrate immediately, and a failing relay does not break the result" + observable_outcomes: + - "exactly one pool.list call, targeting all configured relays" + - "query authors = only uncached/valid pubkeys" + - "DOM updates: cached profile first, then newest fast-relay profile" + - "cache set with the newest (Fast) profile only" + determinism_controls: + - "mock pool.list with deterministic per-relay responses; fixed createdAt ordering" + anti_cheat_rationale: + prevents: + - "over-mocking internal logic" + - "hard-coded return value" + note: "Only the per-relay CALL-COUNT assertion changed (2 -> 1), because the + refactor intentionally batches the fetch. All behavioral assertions + (correct/newest profile rendered, cached hydration, failing relay + tolerated) are unchanged, and a new assertion verifies the single batched + query targets all relays — so coverage is equal or stronger." + relaxation: + did_relax_any_assertion: false + if_true_explain_spec_basis: "" +``` diff --git a/js/services/profileMetadataService.js b/js/services/profileMetadataService.js index 1b2e991b..c6f477b6 100644 --- a/js/services/profileMetadataService.js +++ b/js/services/profileMetadataService.js @@ -106,61 +106,64 @@ async function performProfileFetch({ const newestProfiles = new Map(); - const results = await Promise.allSettled( - relayUrls.map((relayUrl) => - nostr.pool - .list([relayUrl], [filter]) - .then((events) => ({ relayUrl, events })) - ) - ); + // Prefer the L1 SubscriptionManager: ONE deduped, health-gated list() across + // all relays (the pool merges per-relay results) instead of a manual + // per-relay fan-out. Falls back to the legacy per-relay path in manager-less + // environments (tests / mock clients). + const manager = + typeof nostr.getSubscriptionManager === "function" + ? nostr.getSubscriptionManager() + : null; - results.forEach((result, index) => { - const relayUrl = - result.status === "fulfilled" - ? result.value.relayUrl - : relayUrls[index]; + let events = []; + if (manager) { + try { + const result = await manager.list({ filters: [filter], relays: relayUrls }); + events = Array.isArray(result) ? result : []; + } catch (error) { + logger.warn("[profileMetadata] Batched profile fetch failed:", error); + } + } else { + const results = await Promise.allSettled( + relayUrls.map((relayUrl) => + nostr.pool + .list([relayUrl], [filter]) + .then((relayEvents) => ({ relayUrl, relayEvents })) + ) + ); + results.forEach((result, index) => { + if (result.status !== "fulfilled") { + logger.warn( + `[profileMetadata] Failed to fetch profiles from relay ${relayUrls[index]}:`, + result.reason, + ); + return; + } + if (Array.isArray(result.value.relayEvents)) { + events.push(...result.value.relayEvents); + } + }); + } - if (result.status !== "fulfilled") { - logger.warn( - `[profileMetadata] Failed to fetch profiles from relay ${relayUrl}:`, - result.reason, - ); - return; + for (const event of events) { + const pubkey = normalizePubkey(event?.pubkey); + if (!pubkey) { + continue; } - const events = Array.isArray(result.value.events) - ? result.value.events - : []; - if (!events.length) { - return; + const createdAt = Number.isFinite(event?.created_at) ? event.created_at : 0; + const previous = newestProfiles.get(pubkey); + if (previous && previous.createdAt >= createdAt) { + continue; } - for (const event of events) { - const pubkey = normalizePubkey(event?.pubkey); - if (!pubkey) { - continue; - } - - const createdAt = Number.isFinite(event?.created_at) - ? event.created_at - : 0; - const previous = newestProfiles.get(pubkey); - if (previous && previous.createdAt >= createdAt) { - continue; - } - - const profile = parseProfileEvent(event, defaultProfileImage); - if (!profile) { - continue; - } - - newestProfiles.set(pubkey, { - createdAt, - profile, - event, - }); + const profile = parseProfileEvent(event, defaultProfileImage); + if (!profile) { + continue; } - }); + + newestProfiles.set(pubkey, { createdAt, profile, event }); + } return newestProfiles; } diff --git a/tests/app-batch-fetch-profiles.test.mjs b/tests/app-batch-fetch-profiles.test.mjs index 2e916946..fd3fcab5 100644 --- a/tests/app-batch-fetch-profiles.test.mjs +++ b/tests/app-batch-fetch-profiles.test.mjs @@ -107,9 +107,22 @@ test("batchFetchProfiles handles fast and failing relays", async () => { // console.log("poolListCalls.length:", poolListCalls.length); // console.log("nostrClient.relays:", nostrClient.relays); - assert.equal(poolListCalls.length, 2, "expected a query per relay"); + // Spec correction (SCN-profiles-batched): profile fetches are now BATCHED + // through the L1 SubscriptionManager — ONE query across all relays instead of + // one query per relay. The pool merges per-relay results and tolerates a + // failing relay internally, so the fast relay's profile still renders. + assert.equal( + poolListCalls.length, + 1, + "expected a single batched query across all relays", + ); const [fastCall] = poolListCalls; + assert.deepEqual( + fastCall.relays, + [FAST_RELAY, FAIL_RELAY], + "batched query should target all configured relays", + ); assert.deepEqual( fastCall.filters[0].authors, [VALID_PUBKEY],