1/** 2 * Load independent full-text indexes without letting one unavailable artifact 3 * discard siblings that loaded successfully. 4 */ 5export function loadSearchIndexArtifact(serialized, options, loadJS) { 6 const { phrasePostings, ...artifact } = JSON.parse(serialized); 7 const index = loadJS(artifact, options); 8 if (phrasePostings && typeof phrasePostings === 'object') { 9 index.phrasePostings = new Map( 10 Object.entries(phrasePostings).map(([key, documentIds]) => { 11 const recordIds = documentIds.map((documentId) => { 12 if (!Object.hasOwn(artifact.documentIds, documentId)) { 13 throw new Error(`exact phrase posting ${key} references missing document ${documentId}`); 14 } 15 return artifact.documentIds[documentId]; 16 }); 17 return [key, new Set(recordIds)]; 18 }), 19 ); 20 } 21 return index; 22} 23 24export async function loadAvailableSearchIndexes( 25 specs, 26 { fetchImpl = globalThis.fetch, loadJSON } = {}, 27) { 28 const settled = await Promise.allSettled(specs.map(async ({ url, options }) => { 29 const response = await fetchImpl(url); 30 if (!response.ok) { 31 throw new Error(`search index fetch failed (${url}): HTTP ${response.status}`); 32 } 33 return loadJSON(await response.text(), options); 34 })); 35 36 const loaded = []; 37 const failures = []; 38 settled.forEach((result, index) => { 39 if (result.status === 'fulfilled') { 40 loaded.push({ url: specs[index].url, index: result.value }); 41 return; 42 } 43 failures.push({ 44 url: specs[index].url, 45 error: result.reason instanceof Error ? result.reason : new Error(String(result.reason)), 46 }); 47 }); 48 49 if (loaded.length === 0) { 50 throw new AggregateError( 51 failures.map(failure => failure.error), 52 'all full-text search indexes failed to load', 53 ); 54 } 55 56 return { indexes: loaded.map(entry => entry.index), loaded, failures }; 57} 58 59/** 60 * Cache each successfully loaded index independently while retrying only the 61 * artifacts that remain unavailable. Concurrent callers share one attempt, 62 * and results always follow the original spec order even when recovery happens 63 * across multiple attempts. 64 */ 65export function createResilientSearchIndexLoader( 66 specs, 67 { fetchImpl = globalThis.fetch, loadJSON } = {}, 68) { 69 const loadedByUrl = new Map(); 70 let inFlight = null; 71 72 const orderedIndexes = () => specs 73 .filter(spec => loadedByUrl.has(spec.url)) 74 .map(spec => loadedByUrl.get(spec.url)); 75 76 const load = () => { 77 if (inFlight) return inFlight; 78 79 const missingSpecs = specs.filter(spec => !loadedByUrl.has(spec.url)); 80 if (missingSpecs.length === 0) { 81 return Promise.resolve({ indexes: orderedIndexes(), failures: [], complete: true }); 82 } 83 84 inFlight = loadAvailableSearchIndexes(missingSpecs, { fetchImpl, loadJSON }) 85 .then(({ loaded, failures }) => { 86 for (const entry of loaded) loadedByUrl.set(entry.url, entry.index); 87 const result = { 88 indexes: orderedIndexes(), 89 failures, 90 complete: loadedByUrl.size === specs.length, 91 }; 92 if (!result.complete) inFlight = null; 93 return result; 94 }) 95 .catch((error) => { 96 inFlight = null; 97 if (loadedByUrl.size > 0 && error instanceof AggregateError) { 98 return { 99 indexes: orderedIndexes(), 100 failures: missingSpecs.map((spec, index) => ({ 101 url: spec.url, 102 error: error.errors[index] instanceof Error 103 ? error.errors[index] 104 : new Error(String(error.errors[index])), 105 })), 106 complete: false, 107 }; 108 } 109 throw error; 110 }); 111 112 return inFlight; 113 }; 114 115 return { load }; 116}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.