1// Pure data layer for composing QueryBuilder results with the main archive filters. 2// 3// QueryBuilder record queries feed this module's record-id subset into App.js. 4// App then renders that subset through the archive cards, RecordView, facets, 5// timeline, and entity browser. The facet and entity derivations below keep 6// those secondary views on the same record subset. Aggregate queries have no 7// record ids, so they remain tabular inside the analytics route (issue #135, 8// Path A, reaffirmed by Joe on 2026-07-09). 9// 10// Path A's load-bearing transform turns query result rows into a record-id set 11// and intersects the archive against it. This module owns those pure composition 12// transforms: no React, no DOM, no SQLite. Every function takes plain values and 13// returns plain values, so the whole thing is unit-testable under `node --test`. 14// 15// The three concerns are deliberately separate: 16// - templateIsComposable(template) -- static: can this query feed the filter at 17// all? A record-returning template can; an aggregate (COUNT/GROUP BY) can't. 18// This gates dispatch into the archive view, decided before the query runs. 19// - extractRecordIds(rows) -- runtime: which records did it return? Empty 20// is a real answer (the query matched nothing), not a sign of non-composability. 21// - intersectByRecordIds(...) -- runtime: apply the resulting id set. 22// Composability is a property of the query shape, not of the row count, so it is 23// read from the template, never inferred from how many rows came back. 24 25/** 26 * Normalise a record id to a string for membership comparison. 27 * 28 * Archive records store string ids ("dissertation-1986", and numeric-looking ids 29 * as strings), while a SQLite query can return a numeric-looking id column as a 30 * JS number. Comparing 5 to "5" with Set membership would silently match nothing, 31 * so both sides are coerced to strings here. A null/undefined/empty id is not a 32 * usable handle and returns null so callers can drop it. 33 * 34 * @param {unknown} id 35 * @returns {string | null} 36 */ 37function normalizeId(id) { 38 if (id === null || id === undefined) return null; 39 const str = String(id); 40 return str === '' ? null : str; 41} 42 43/** 44 * Pull the record-id column out of query result rows into a deduplicated, 45 * order-preserving array of string ids. 46 * 47 * Aggregate queries (COUNT/GROUP BY templates) select no id column, so their rows 48 * yield an empty array; so does a record query that matched nothing. The two are 49 * told apart by templateIsComposable (a template property), not by this return -- 50 * here an empty array just means "no ids in these rows". A row with a null/empty id 51 * is skipped, and first-occurrence order is preserved so a caller can report 52 * "N records" consistently. 53 * 54 * @param {Array<Record<string, unknown>> | null | undefined} rows 55 * @param {string} [idColumn='id'] - the result column holding the record id. 56 * @returns {string[]} 57 */ 58export function extractRecordIds(rows, idColumn = 'id') { 59 if (!Array.isArray(rows)) return []; 60 const seen = new Set(); 61 const ids = []; 62 for (const row of rows) { 63 if (!row || typeof row !== 'object') continue; 64 const id = normalizeId(row[idColumn]); 65 if (id === null || seen.has(id)) continue; 66 seen.add(id); 67 ids.push(id); 68 } 69 return ids; 70} 71 72/** 73 * True when a query template can compose with the main archive filters: it returns 74 * record rows rather than an aggregate. This gates dispatch into the archive and is 75 * a static property of the template, decided before the query runs. A record-returning 76 * template that happens to match zero rows is still composable because applying it 77 * narrows the archive to nothing, so the row count must not enter this decision. 78 * 79 * The default is conservative: a template is composable only when it explicitly 80 * declares `composable: true`. An unmarked or aggregate template returns false, so 81 * a query whose result cannot be a record filter stays in the tabular results view. 82 * 83 * @param {{ composable?: boolean } | null | undefined} template 84 * @returns {boolean} 85 */ 86export function templateIsComposable(template) { 87 return template != null && template.composable === true; 88} 89 90/** 91 * Resolve a template's field values against its declared defaults so a caller can 92 * build SQL without tripping over an unset field. 93 * 94 * QueryBuilder keeps fieldValues in React state that an effect repopulates only 95 * after a template switch has rendered. On the render between the switch and that 96 * effect, fieldValues still carries the previous template's keys, so the newly 97 * selected template's fields are missing. A template whose buildSql dereferences a 98 * text field (values.SEARCH_TERM.replace(...)) then throws during render and blanks 99 * the whole app (issue #525). Resolving here fills every field the template 100 * declares -- the caller's value when present, the field default otherwise -- and 101 * returns only those fields, dropping stale keys left over from a prior template. 102 * It mirrors the `values[part] ?? field.default` fallback the query sentence UI 103 * already uses, so the built SQL matches the sentence the user sees. 104 * 105 * Nullish coalescing is deliberate: an empty string or a 0 the user entered is a 106 * real value and is kept; only null/undefined falls back to the default. 107 * 108 * @param {{fields?: Record<string, {default?: unknown}>} | null | undefined} template 109 * @param {Record<string, unknown> | null | undefined} values 110 * @returns {Record<string, unknown>} 111 */ 112export function resolveFieldValues(template, values) { 113 const fields = template && typeof template === 'object' && template.fields 114 ? template.fields 115 : {}; 116 const source = values && typeof values === 'object' ? values : {}; 117 const resolved = {}; 118 for (const [key, field] of Object.entries(fields)) {
119 resolved[key] = source[key] ?? (field ? field.default : undefined); 120 } 121 return resolved; 122} 123 124/** 125 * Narrow an archive record array to the records named by recordIds, preserving 126 * the input array's existing order and sort. 127 * 128 * The recordIds argument is deliberately nullable, and null and [] mean different 129 * things: 130 * - null -> no record filter is active; every record passes through. 131 * - [] -> a query ran and matched zero records; nothing passes. 132 * - [a, b...] -> only records whose id is in the set pass. 133 * That distinction is why a recordIds filter slot has to be null-by-default rather 134 * than [] -by-default: an empty array is a real, restrictive result, not "unset". 135 * 136 * Ids are string-normalised on both sides so a numeric query id matches a string 137 * record id. Records with a null/empty id never match a non-null filter. 138 * 139 * @param {Array<{ id?: unknown }>} records 140 * @param {Array<string | number> | null | undefined} recordIds 141 * @returns {Array<{ id?: unknown }>} 142 */ 143export function intersectByRecordIds(records, recordIds) { 144 if (!Array.isArray(records)) return []; 145 if (recordIds === null || recordIds === undefined) return records; 146 147 const wanted = new Set(); 148 for (const id of recordIds) { 149 const norm = normalizeId(id); 150 if (norm !== null) wanted.add(norm); 151 } 152 if (wanted.size === 0) return []; 153 154 return records.filter((r) => { 155 const id = normalizeId(r && r.id); 156 return id !== null && wanted.has(id); 157 }); 158} 159 160/** 161 * Restrict the archive's facet choices to values present in a record subset. 162 * 163 * The source facet arrays already carry the archive's published ordering, 164 * including the canonical era order. Filtering those arrays preserves that 165 * order without copying taxonomy values into this module. 166 * 167 * Active category and era selections remain available even when the query 168 * subset does not contain them, so the sidebar can still remove those filters. 169 * 170 * @param {{categories?: string[], eras?: string[], publications?: string[]}} facets 171 * @param {Array<{categories?: string[], era?: string, pub?: string}>} records 172 * @param {{categories?: string[], era?: string | null}} [selected] 173 * @returns {{categories: string[], eras: string[], publications: string[]}} 174 */ 175export function deriveFacetsForRecords(facets, records, selected = {}) { 176 const available = { 177 categories: new Set(), 178 eras: new Set(), 179 publications: new Set(), 180 }; 181 182 for (const record of Array.isArray(records) ? records : []) { 183 for (const category of Array.isArray(record?.categories) ? record.categories : []) { 184 available.categories.add(category); 185 } 186 if (record?.era) available.eras.add(record.era); 187 if (record?.pub) available.publications.add(record.pub); 188 } 189 190 for (const category of Array.isArray(selected.categories) ? selected.categories : []) { 191 available.categories.add(category); 192 } 193 if (selected.era) available.eras.add(selected.era); 194 195 const source = facets && typeof facets === 'object' ? facets : {}; 196 return { 197 categories: (source.categories || []).filter(value => available.categories.has(value)), 198 eras: (source.eras || []).filter(value => available.eras.has(value)), 199 publications: (source.publications || []).filter(value => available.publications.has(value)), 200 }; 201} 202 203/** 204 * Restrict entity metadata and entity-to-record links to a record subset. 205 * 206 * Entity payloads carry archive-wide mention totals and relationships. Query 207 * results need counts computed from their records so unrelated entities do not 208 * remain visible with zero matching records. Each entity is counted once per 209 * record, and the source entity order is preserved. 210 * 211 * @param {Array<{id?: unknown, totalMentions?: number}>} entities 212 * @param {Record<string, Array<unknown>>} recordEntityMap 213 * @param {Array<{id?: unknown}>} records 214 * @returns {{entities: Array<object>, recordIdsByEntity: Map<string, string[]>}} 215 */ 216export function deriveEntityScope(entities, recordEntityMap, records) { 217 const sourceEntities = Array.isArray(entities) ? entities : []; 218 const availableEntityIds = new Set( 219 sourceEntities.map(entity => normalizeId(entity?.id)).filter(id => id !== null) 220 ); 221 const relationships = recordEntityMap && typeof recordEntityMap === 'object' 222 ? recordEntityMap 223 : {}; 224 const recordIdsByEntity = new Map(); 225 const seenRecordIds = new Set(); 226 227 for (const record of Array.isArray(records) ? records : []) { 228 const recordId = normalizeId(record?.id); 229 if (recordId === null || seenRecordIds.has(recordId)) continue; 230 seenRecordIds.add(recordId); 231 232 const seenEntityIds = new Set(); 233 const relatedIds = Array.isArray(relationships[recordId]) ? relationships[recordId] : []; 234 for (const relatedId of relatedIds) { 235 const entityId = normalizeId(relatedId); 236 if ( 237 entityId === null || 238 seenEntityIds.has(entityId) || 239 !availableEntityIds.has(entityId) 240 ) continue; 241 242 seenEntityIds.add(entityId); 243 if (!recordIdsByEntity.has(entityId)) recordIdsByEntity.set(entityId, []); 244 recordIdsByEntity.get(entityId).push(recordId); 245 } 246 } 247 248 return { 249 entities: sourceEntities 250 .filter(entity => recordIdsByEntity.has(normalizeId(entity?.id))) 251 .map(entity => ({ 252 ...entity, 253 totalMentions: recordIdsByEntity.get(normalizeId(entity.id)).length, 254 })), 255 recordIdsByEntity, 256 }; 257} 258 259/** 260 * Use the payload entity index until a query scope is active. 261 * 262 * The payload contains entities that are not represented in recordEntityMap and 263 * archive-wide mention totals that cannot be reconstructed from that map. Query 264 * results need the derived subset; ordinary entity browsing must keep the source 265 * array and its metadata untouched. 266 * 267 * @param {Array<object>} entities 268 * @param {Record<string, Array<unknown>>} recordEntityMap 269 * @param {Array<{id?: unknown}>} records 270 * @param {boolean} queryActive 271 * @returns {{entities: Array<object>, recordIdsByEntity: Map<string, string[]> | null}} 272 */ 273export function getEntityScope(entities, recordEntityMap, records, queryActive) { 274 if (!queryActive) { 275 return { 276 entities: Array.isArray(entities) ? entities : [], 277 recordIdsByEntity: null, 278 }; 279 } 280 281 return deriveEntityScope(entities, recordEntityMap, records); 282}
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.