From 8439b358c33fa94f527fb87e7ab441dfca4f74bc Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 31 May 2026 22:36:42 -0400 Subject: [PATCH] Linked Designs P3d: batched prefetch on cold start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Last Phase 3 polish piece per LINKED_DESIGNS_FEATURE.md §14. The cold-start path used to fire one parallel queryRelays per linked layer — a host with 20 distinct linked sources opened 20 sockets to each relay in the union. Now restoreAutosave issues one batched relay round-trip via the new linkedDesigns.prefetchBatch. Mechanics: 1. queryRelays accepts either a single filter object (existing callers) or an array of filters; arrays spread into the NIP-01 REQ after the sub id (OR semantics). One-line change at the send site; zero impact on existing call sites. 2. prefetchBatch(specs) skips anything already in the in-memory cache, inflight map, or disk cache — partial cache hits only fetch what's missing. Distinct address-coord refs are grouped by (kind, pubkey) into one filter each (#d set of dTags); pinned event ids collapse into a single ids-filter. Events bucket by id (for pinned matches) and by address coord with latest created_at wins (for ref matches). 3. restoreAutosave's per-spec resolveLinked loop becomes a single prefetchBatch call. The disk-cache pre-filter inside prefetchBatch means a reload of a previously-resolved host pays zero network cost; only newly-linked refs hit the wire. Per-layer resolveLinked calls in the renderer still work the way they did — this only changes the bulk warm-up path. Interactive adds (one new linked layer at a time) keep their single-filter fast path. --- src/index.html | 118 ++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 107 insertions(+), 11 deletions(-) diff --git a/src/index.html b/src/index.html index eb0ee56..ed85af4 100644 --- a/src/index.html +++ b/src/index.html @@ -6856,6 +6856,98 @@ function keyFor(linkSpec) { return _cacheKey(linkSpec || {}); } + // Fetch many linked specs in one round-trip. Combines all + // distinct address-coord refs into a multi-filter REQ + // (grouped by author + kind for relay-side efficiency) and + // any pinned event ids into a single ids-filter. Skips + // anything already in the in-memory or disk cache so a + // partial cache hit only fetches what's actually missing. + // Populates both caches on completion. + // + // Used by restoreAutosave to warm the cache for every + // linked layer in a saved design in one relay round-trip + // instead of N parallel queries. + async function prefetchBatch(specs) { + if (!Array.isArray(specs) || !specs.length) return; + // Resolve which specs actually need a network fetch. + const needFetch = []; + const seenKeys = new Set(); + for (const spec of specs) { + if (!spec) continue; + const key = _cacheKey(spec); + if (!key || seenKeys.has(key)) continue; + seenKeys.add(key); + if (_fromCache(key)) continue; + if (inflight.has(key)) continue; + if (_diskRead(key)) continue; + needFetch.push({ key, spec }); + } + if (!needFetch.length) return; + // Build batched filters. + const idList = []; + const byAuthorKind = new Map(); // "kind:pubkey" → Set + for (const { spec } of needFetch) { + if (spec.pinnedEventId && /^[0-9a-f]{64}$/i.test(spec.pinnedEventId)) { + idList.push(spec.pinnedEventId); + continue; + } + const coord = _parseAddressCoord(spec.ref); + if (!coord) continue; + const k = `${coord.kind}:${coord.pubkey}`; + let set = byAuthorKind.get(k); + if (!set) { set = new Set(); byAuthorKind.set(k, set); } + set.add(coord.dTag); + } + const filters = []; + if (idList.length) filters.push({ ids: [...new Set(idList)], limit: idList.length }); + for (const [k, dTags] of byAuthorKind) { + const [kindStr, pubkey] = k.split(':'); + filters.push({ + kinds: [Number(kindStr)], + authors: [pubkey], + '#d': [...dTags], + limit: dTags.size * 2, + }); + } + if (!filters.length) return; + const relays = typeof _vaultRelayUnion === 'function' + ? _vaultRelayUnion() + : splitRelayList(state.community?.relays || ''); + let events; + try { + events = await queryRelays(relays, filters, { timeoutMs: 4500 }); + } catch (err) { + console.warn('[linked-designs] batched prefetch failed', err); + return; + } + // Bucket events: by id (for pinned lookups) and by address + // coord (for ref lookups; latest created_at wins). + const byId = new Map(); + const byAddr = new Map(); + for (const ev of (events || [])) { + byId.set(ev.id, ev); + const dTag = (ev.tags || []).find(t => t[0] === 'd' && t[1] != null)?.[1] || ''; + const addr = `${ev.kind}:${ev.pubkey}:${dTag}`; + const prev = byAddr.get(addr); + if (!prev || (ev.created_at || 0) > (prev.created_at || 0)) byAddr.set(addr, ev); + } + // Match each requested spec to an event + seed caches. + for (const { key, spec } of needFetch) { + let ev = null; + if (spec.pinnedEventId) { + ev = byId.get(spec.pinnedEventId) || null; + } else { + const coord = _parseAddressCoord(spec.ref); + if (coord) ev = byAddr.get(`${coord.kind}:${coord.pubkey}:${coord.dTag}`) || null; + } + if (!ev) continue; + const entry = await _entryFromEvent(ev); + if (!entry) continue; + cache.set(key, { ...entry, fetchedAt: _now() }); + _diskWrite(key, ev); + } + } + // Populate both caches from a freshly-published source event // so a layer that was just extracted-and-linked paints // instantly without waiting for relay propagation. Returns @@ -6871,7 +6963,7 @@ return entry; } - return { resolveLinked, walkLinkedTree, walkContributors, clearCache, peek, keyFor, seed, _parseAddressCoord, _cacheKey }; + return { resolveLinked, walkLinkedTree, walkContributors, clearCache, peek, keyFor, seed, prefetchBatch, _parseAddressCoord, _cacheKey }; })(); // Walk the host's linked tree to collect (a) distinct contributor @@ -8196,7 +8288,11 @@ const subId = `sub-${Date.now()}-${Math.floor(Math.random() * 100000)}`; ws.addEventListener('open', () => { try { - ws.send(JSON.stringify(['REQ', subId, filters])); + // NIP-01 REQ accepts multiple filters spread after the + // sub id (OR semantics). Callers can pass a single + // filter object (legacy) or an array; both work. + const filterList = Array.isArray(filters) ? filters : [filters]; + ws.send(JSON.stringify(['REQ', subId, ...filterList])); } catch { clearTimeout(timer); markRelayFailure(relay, 'Could not send query', { context: 'read' }); @@ -27862,13 +27958,13 @@ if ((parsed.appMode || parsed.ui?.appMode) === 'designer') { loadCurrentDiscDesignLayers(); } - // Kick off the linked-design resolver for every restored - // linked layer so the cache warms in parallel with the first - // render. The renderer's async branch handles the empty-cache - // case correctly on its own, but pre-warming dedupes - // duplicate refs across slides via the inflight map and lets - // the slot's renderResolved fire from a synchronous peek on - // subsequent re-renders. Non-blocking. + // Warm the linked-design cache for every restored linked + // layer in one batched relay round-trip (instead of N + // parallel queries). prefetchBatch checks the disk cache + // first and only fetches refs that are actually missing, + // so common reload scenarios pay zero network cost. + // Non-blocking; the renderer's async branch picks up the + // freshly-warmed cache on first paint. const linkedSpecs = []; const collect = (layers) => { if (!Array.isArray(layers)) return; @@ -27882,8 +27978,8 @@ if (Array.isArray(state.deck?.slides)) { for (const s of state.deck.slides) collect(s?.layers); } - for (const spec of linkedSpecs) { - try { linkedDesigns.resolveLinked(spec); } catch {} + if (linkedSpecs.length) { + try { linkedDesigns.prefetchBatch(linkedSpecs); } catch {} } } catch (error) { console.warn('Autosave restore failed', error);