PageSourceSearch

https://pressthink.org/j/rosen-archive/frontend/services/queryComposition.js?v=3.8.36

js pressthink.org collected 2026-10-02 04:26:22 UTC 12,113 bytes, 282 lines download raw bytes

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.