From 59069cdfc015cf5812a94074198cf254e6c66fdd Mon Sep 17 00:00:00 2001 From: VibhavSetlur Date: Thu, 27 Aug 2026 12:30:03 -0500 Subject: [PATCH 1/2] fix(biochem): fetch Solr 9 nested stoichiometry children and stop querying the removed parent field Use the child transformer to retrieve stoichiometry alongside thermodynamics and normalize participants at the API boundary. Restrict nested batch and reverse lookups to parent documents, and omit the nested child path from reaction quick-search fields. --- CHANGELOG.md | 9 ++ VERSION.md | 2 +- lib/api/biochem.ts | 140 +++++++++++++++++--- package-lock.json | 4 +- package.json | 2 +- tests/unit/api/biochemStoichiometry.test.ts | 125 +++++++++++++++++ tests/unit/api/biochemThermo.test.ts | 4 +- 7 files changed, 264 insertions(+), 22 deletions(-) create mode 100644 tests/unit/api/biochemStoichiometry.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9618456d..b835a619 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] - TBD +## [3.4.1] - 2026-08-27 + +### Fixed +- Reaction quick-search no longer queries Solr 9 nested stoichiometry child paths as parent fields, and reaction details now return normalized stoichiometry participants. +- Solr 9 compound batch and reverse reaction lookups now restrict results to parent documents. + +--- + + ### Removed - Unused tracked `.gsd/` planning scaffolding, `.clauderules`, and orphan `gsd-opencode` submodule gitlink. - Dead GSD references from `.gitignore`, `INDEX.md`, `README.md`, `docs/DEVELOPER_GUIDE.md`, `eslint.config.mjs`, and `vitest.config.ts`. diff --git a/VERSION.md b/VERSION.md index 18091983..47b322c9 100644 --- a/VERSION.md +++ b/VERSION.md @@ -1 +1 @@ -3.4.0 +3.4.1 diff --git a/lib/api/biochem.ts b/lib/api/biochem.ts index 5b745e88..1026236d 100644 --- a/lib/api/biochem.ts +++ b/lib/api/biochem.ts @@ -34,6 +34,7 @@ export interface Reaction { deltagerr: number; reversibility: string; stoichiometry: string; + participants?: StoichiometryParticipant[]; status: string; aliases: string[]; ec_numbers: string[]; @@ -821,6 +822,17 @@ function sortDocs( }); } +export interface StoichiometryParticipant { + compound: string; + coefficient: number; + compartment: number; + name: string; + /** true when the participant is consumed (coefficient < 0). */ + is_reactant: boolean; + charge?: number; + formula?: string; +} + /** Coerces a Solr thermodynamics child's `energy`/`error` value to a finite number or null. */ function coerceThermodynamicsNumber(value: unknown): number | null { const raw = Array.isArray(value) ? value[0] : value; @@ -867,6 +879,83 @@ export function normalizeThermodynamics(doc: unknown): ThermodynamicsRecord[] { return results; } +/** + * Normalizes raw Solr nested or legacy reaction stoichiometry into typed + * participants. Pure and never throws: malformed or missing input yields `[]`. + */ +export function normalizeStoichiometry(doc: unknown): StoichiometryParticipant[] { + if (!doc || typeof doc !== 'object') return []; + const record = doc as Record; + + if (Array.isArray(record.stoichiometry) || Array.isArray(record._childDocuments_)) { + const children = Array.isArray(record.stoichiometry) + ? record.stoichiometry + : record._childDocuments_ as unknown[]; + const results: Array = []; + let canSortByNestPath = true; + for (const child of children) { + if (!child || typeof child !== 'object') continue; + const c = child as Record; + if (typeof c.doc_type === 'string' && c.doc_type !== 'stoichiometry') continue; + if (typeof c.compound !== 'string' || c.compound.length === 0) continue; + const coefficient = coerceThermodynamicsNumber(c.coefficient); + if (coefficient === null) continue; + const nestMatch = typeof c._nest_path_ === 'string' + ? /\/stoichiometry#(\d+)$/.exec(c._nest_path_) + : null; + if (!nestMatch) canSortByNestPath = false; + const entry: StoichiometryParticipant & { nestPath?: number } = { + compound: c.compound, + coefficient, + compartment: coerceThermodynamicsNumber(c.compartment) ?? 0, + name: typeof c.participant_name === 'string' && c.participant_name.length > 0 + ? c.participant_name : c.compound, + is_reactant: typeof c.is_reactant === 'boolean' ? c.is_reactant : coefficient < 0, + nestPath: nestMatch ? Number(nestMatch[1]) : undefined, + }; + const charge = coerceThermodynamicsNumber(c.participant_charge); + if (charge !== null) entry.charge = charge; + if (typeof c.participant_formula === 'string' && c.participant_formula.length > 0) entry.formula = c.participant_formula; + results.push(entry); + } + if (canSortByNestPath) results.sort((a, b) => a.nestPath! - b.nestPath!); + return results.map(({ compound, coefficient, compartment, name, is_reactant, charge, formula }) => ({ + compound, + coefficient, + compartment, + name, + is_reactant, + ...(charge === undefined ? {} : { charge }), + ...(formula === undefined ? {} : { formula }), + })); + } + + if (typeof record.stoichiometry !== 'string') return []; + const results: StoichiometryParticipant[] = []; + for (const segment of record.stoichiometry.split(';')) { + if (!segment) continue; + const [coefficientRaw, compound = '', compartmentRaw, , nameRaw = ''] = segment.split(':', 5); + const coefficient = coerceThermodynamicsNumber(coefficientRaw); + if (coefficient === null || compound.length === 0) continue; + const name = nameRaw.length >= 2 && nameRaw.startsWith('"') && nameRaw.endsWith('"') + ? nameRaw.slice(1, -1) : nameRaw; + results.push({ + compound, + coefficient, + compartment: coerceThermodynamicsNumber(compartmentRaw) ?? 0, + name, + is_reactant: coefficient < 0, + }); + } + return results; +} + +export function serializeStoichiometry(participants: StoichiometryParticipant[]): string { + return participants.map(({ coefficient, compound, compartment, name }) => + `${coefficient}:${compound}:${compartment}:0:"${name}"`, + ).join(';'); +} + /** * Apply MUI column filter items to row objects locally (for APIs that cannot express filters server-side). * Uses the same operator semantics as Solr-backed biochem when used with `get*FromModelseedApi`. @@ -901,6 +990,9 @@ const MIN_WILDCARD_QUERY_LENGTH = 3; /** Reaction search fields matching legacy `rxn_sFields`. */ const RXN_SEARCH_FIELDS = ['id', 'name', 'status', 'ec_numbers', 'aliases', 'pathways', 'stoichiometry', 'notes']; +/** Solr 9 nested stoichiometry is a child path, not a queryable parent field; querying it yields HTTP 400 "undefined field stoichiometry". */ +const RXN_SEARCH_FIELDS_NESTED = RXN_SEARCH_FIELDS.filter((field) => field !== 'stoichiometry'); + /** Reaction visible fields matching legacy `rxnOpts.visible`. */ const RXN_VISIBLE = [ 'name', 'id', 'definition', 'deltag', 'deltagerr', 'reversibility', @@ -948,17 +1040,16 @@ const CPD_VISIBLE = [ * ``` */ export async function getReactions(opts: SolrQueryOpts = {}): Promise> { + const nested = await hasNestedSchema('reactions'); const mergedOpts: SolrQueryOpts = { limit: 25, offset: 0, sort: { field: 'id' }, - searchFields: RXN_SEARCH_FIELDS, + searchFields: nested ? RXN_SEARCH_FIELDS_NESTED : RXN_SEARCH_FIELDS, visible: RXN_VISIBLE, ...opts, }; - // Reactions page is intentionally pinned to legacy Solr. - const nested = await hasNestedSchema('reactions'); const queryOpts = nested ? { ...mergedOpts, @@ -1073,11 +1164,20 @@ export async function getReactionById(id: string): Promise { let url = `${solrCorpusEndpoint('reactions')}/select?wt=json&q=id:${id}`; const nested = await hasNestedSchema('reactions'); if (nested) { - url += `&fq=${encodeURIComponent(parentDocTypeFilter('reactions'))}&fl=${encodeURIComponent('*,[child childFilter=doc_type:thermodynamics]')}`; + url += `&fq=${encodeURIComponent(parentDocTypeFilter('reactions'))}&fl=${encodeURIComponent('*,[child childFilter="doc_type:thermodynamics OR doc_type:stoichiometry" limit=200]')}`; } const res = await fetchSolr(url); const raw = res.docs[0]; - return raw ? { ...raw, thermodynamics: normalizeThermodynamics(raw) } : raw; + if (!raw) return raw; + const participants = normalizeStoichiometry(raw); + return { + ...raw, + thermodynamics: normalizeThermodynamics(raw), + participants, + stoichiometry: typeof raw.stoichiometry === 'string' + ? raw.stoichiometry + : participants.length > 0 ? serializeStoichiometry(participants) : '', + }; } /** @@ -1137,22 +1237,23 @@ export async function getCompoundsForReaction(ids: string[]): Promise> { +async function getCompoundsByIdsWithFields(ids: string[], fields: string[]): Promise> { const uniqueIds = Array.from(new Set(ids.filter(Boolean))); - if (uniqueIds.length === 0) return Promise.resolve(new Map()); + if (uniqueIds.length === 0) return new Map(); const idQuery = uniqueIds.map((id) => `id:${id}`).join(' OR '); const fl = fields.join(','); // Batch ID fetch is currently Solr-backed for both modes. - const url = `${solrCorpusEndpoint('compounds')}/select?wt=json&q=(${idQuery})&rows=${uniqueIds.length}&fl=${fl}`; - - return fetchSolr(url).then((res) => { - const map = new Map(); - for (const doc of res.docs) { - map.set(doc.id, doc); - } - return map; - }); + let url = `${solrCorpusEndpoint('compounds')}/select?wt=json&q=(${idQuery})&rows=${uniqueIds.length}&fl=${fl}`; + if (await hasNestedSchema('compounds')) { + url += `&fq=${encodeURIComponent(parentDocTypeFilter('compounds'))}`; + } + const res = await fetchSolr(url); + const map = new Map(); + for (const doc of res.docs) { + map.set(doc.id, doc); + } + return map; } /** @@ -1180,7 +1281,12 @@ export async function findReactionsForCompound( const sort = opts.sort; // Reverse compound lookup remains Solr-backed for now. - let url = `${solrCorpusEndpoint('reactions')}/select?wt=json&q=equation:*${cpdId}*&fl=*`; + const nested = await hasNestedSchema('reactions'); + const query = nested && /^[A-Za-z0-9_]+$/.test(cpdId) + ? `{!parent which="doc_type:reaction"}doc_type:stoichiometry AND compound:${cpdId}` + : `equation:*${cpdId}*`; + let url = `${solrCorpusEndpoint('reactions')}/select?wt=json&q=${query}&fl=*`; + if (nested) url += `&fq=${encodeURIComponent(parentDocTypeFilter('reactions'))}`; if (limit) url += `&rows=${limit}`; if (offset) url += `&start=${offset}`; if (sort) { diff --git a/package-lock.json b/package-lock.json index 1ccb42a4..0ce8826c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "modelseed-ui", - "version": "3.4.0", + "version": "3.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "modelseed-ui", - "version": "3.4.0", + "version": "3.4.1", "dependencies": { "@emotion/cache": "^11.14.0", "@emotion/react": "^11.14.0", diff --git a/package.json b/package.json index 5acee4e5..c6c38e26 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "modelseed-ui", - "version": "3.4.0", + "version": "3.4.1", "private": true, "scripts": { "predev": "node scripts/sync-version-from-env.mjs", diff --git a/tests/unit/api/biochemStoichiometry.test.ts b/tests/unit/api/biochemStoichiometry.test.ts new file mode 100644 index 00000000..b1ef376e --- /dev/null +++ b/tests/unit/api/biochemStoichiometry.test.ts @@ -0,0 +1,125 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { resetSolrSchemaCache } from '@/lib/api/solrSchema'; + +async function loadBiochemApi() { + vi.resetModules(); + vi.stubEnv('NEXT_PUBLIC_DEPLOYMENT_MODE', 'staging'); + return import('@/lib/api/biochem'); +} + +function mockFetch(nested: { reactions?: boolean; compounds?: boolean }, doc?: Record) { + return vi.spyOn(globalThis, 'fetch').mockImplementation((input: RequestInfo | URL) => { + const url = String(input); + const isProbe = url.includes('rows=0'); + const isCompound = url.includes('/compounds_staging/'); + const found = isCompound ? nested.compounds : nested.reactions; + const body = isProbe + ? { response: { numFound: found ? 1 : 0, start: 0, docs: [] } } + : { response: { numFound: doc ? 1 : 0, start: 0, docs: doc ? [doc] : [] } }; + return Promise.resolve(new Response(JSON.stringify(body), { status: 200 })); + }); +} + +function dataUrl(mock: ReturnType, index = -1): string { + return String(mock.mock.calls.filter(([input]) => !String(input).includes('rows=0')).at(index)?.[0]); +} + +describe('Solr stoichiometry support', () => { + beforeEach(() => resetSolrSchemaCache()); + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + resetSolrSchemaCache(); + }); + + it('normalizes nested children, optional values, and nest-path order', async () => { + const api = await loadBiochemApi(); + const doc = { stoichiometry: [ + { doc_type: 'stoichiometry', compound: 'cpd3', coefficient: '1', compartment: '2', participant_name: 'Three', _nest_path_: '/stoichiometry#2' }, + { doc_type: 'stoichiometry', compound: 'cpd1', coefficient: ['-1.5'], compartment: 0, is_reactant: true, participant_charge: ['-2'], participant_formula: 'H2O', _nest_path_: '/stoichiometry#0' }, + { compound: 'cpd2', coefficient: 1, _nest_path_: '/stoichiometry#1' }, + ] }; + expect(api.normalizeStoichiometry(doc)).toEqual([ + { compound: 'cpd1', coefficient: -1.5, compartment: 0, name: 'cpd1', is_reactant: true, charge: -2, formula: 'H2O' }, + { compound: 'cpd2', coefficient: 1, compartment: 0, name: 'cpd2', is_reactant: false }, + { compound: 'cpd3', coefficient: 1, compartment: 2, name: 'Three', is_reactant: false }, + ]); + expect(api.normalizeStoichiometry({ stoichiometry: [ + { compound: 'first', coefficient: 1 }, { compound: 'second', coefficient: -1 }, + ] }).map((p) => p.compound)).toEqual(['first', 'second']); + }); + + it('parses legacy strings and round-trips them', async () => { + const api = await loadBiochemApi(); + const source = '-1.5:cpd00001:0:0:"Water, liquid";2:cpd00002:1:0:"ATP";bad:cpd:0:0:"bad"'; + const participants = api.normalizeStoichiometry({ stoichiometry: source }); + expect(participants).toEqual([ + { compound: 'cpd00001', coefficient: -1.5, compartment: 0, name: 'Water, liquid', is_reactant: true }, + { compound: 'cpd00002', coefficient: 2, compartment: 1, name: 'ATP', is_reactant: false }, + ]); + expect(api.serializeStoichiometry(participants)).toBe('-1.5:cpd00001:0:0:"Water, liquid";2:cpd00002:1:0:"ATP"'); + }); + + it('returns [] without throwing for absent, malformed, and garbage children', async () => { + const api = await loadBiochemApi(); + for (const doc of [null, undefined, {}, 42, { stoichiometry: [] }, { stoichiometry: [null, 'bad', {}, { compound: '', coefficient: 1 }, { compound: 'ok', coefficient: 'abc' }] }]) { + expect(() => api.normalizeStoichiometry(doc)).not.toThrow(); + expect(api.normalizeStoichiometry(doc)).toEqual([]); + } + }); + + it('fetches nested reaction children and preserves thermodynamics and mapping fields', async () => { + const api = await loadBiochemApi(); + const fetchMock = mockFetch({ reactions: true }, { + id: 'rxn00001', has_atom_mapping: true, atom_mapping_data: ['map'], + thermodynamics: [{ doc_type: 'thermodynamics', source_name: 'GC', energy: 4.18, error: 2.24 }], + stoichiometry: [{ doc_type: 'stoichiometry', compound: 'cpd00001', coefficient: -1, compartment: 0, participant_name: 'H2O', _nest_path_: '/stoichiometry#0' }], + }); + const result = await api.getReactionById('rxn00001'); + const url = dataUrl(fetchMock); + expect(url).toContain(`fq=${encodeURIComponent('doc_type:reaction')}`); + expect(url).toContain(encodeURIComponent('*,[child childFilter="doc_type:thermodynamics OR doc_type:stoichiometry" limit=200]')); + expect(result.participants).toEqual([{ compound: 'cpd00001', coefficient: -1, compartment: 0, name: 'H2O', is_reactant: true }]); + expect(result.stoichiometry).toBe('-1:cpd00001:0:0:"H2O"'); + expect(result.thermodynamics).toEqual([{ source_name: 'GC', energy: 4.18, error: 2.24 }]); + expect(result.atom_mapping_data).toEqual(['map']); + expect(result.has_atom_mapping).toBe(true); + }); + + it('keeps legacy URLs byte-identical and parses legacy participants', async () => { + const api = await loadBiochemApi(); + const fetchMock = mockFetch({ reactions: false }, { id: 'rxn00001', stoichiometry: '-1:cpd00001:0:0:"H2O"' }); + const reaction = await api.getReactionById('rxn00001'); + expect(dataUrl(fetchMock)).toBe('https://staging.modelseed.org/solr/reactions_staging/select?wt=json&q=id:rxn00001'); + expect(reaction.stoichiometry).toBe('-1:cpd00001:0:0:"H2O"'); + expect(reaction.participants).toHaveLength(1); + await api.getReactions({ filterModel: { items: [], quickFilterValues: ['cpd00001'] } }); + expect(dataUrl(fetchMock)).toBe( + `https://staging.modelseed.org/solr/reactions_staging/select?wt=json&fl=name,id,definition,deltag,deltagerr,reversibility,stoichiometry,status,aliases,ec_numbers,is_obsolete,is_transport,ontology,pathways,notes&q=${encodeURIComponent('(id:*cpd00001* OR name:*cpd00001* OR status:*cpd00001* OR ec_numbers:*cpd00001* OR aliases:*cpd00001* OR pathways:*cpd00001* OR stoichiometry:*cpd00001* OR notes:*cpd00001*)')}&rows=25&sort=id asc`, + ); + await api.findReactionsForCompound('cpd00002'); + expect(dataUrl(fetchMock)).toBe('https://staging.modelseed.org/solr/reactions_staging/select?wt=json&q=equation:*cpd00002*&fl=*&rows=25'); + }); + + it('uses parent-only nested quick search, reaction joins, and compound batches', async () => { + const api = await loadBiochemApi(); + const fetchMock = mockFetch({ reactions: true, compounds: true }); + await api.getReactions({ filterModel: { items: [], quickFilterValues: ['cpd00001'] } }); + expect(dataUrl(fetchMock)).toContain(`fq=${encodeURIComponent('doc_type:reaction')}`); + expect(decodeURIComponent(dataUrl(fetchMock))).not.toContain('stoichiometry:'); + await api.findReactionsForCompound('cpd00002'); + expect(decodeURIComponent(dataUrl(fetchMock))).toContain('{!parent which="doc_type:reaction"}doc_type:stoichiometry AND compound:cpd00002'); + expect(dataUrl(fetchMock)).toContain(`fq=${encodeURIComponent('doc_type:reaction')}`); + await api.findReactionsForCompound('cpd*'); + expect(decodeURIComponent(dataUrl(fetchMock))).toContain('q=equation:*cpd**'); + await api.getCompoundsByIds(['cpd00001']); + expect(dataUrl(fetchMock)).toContain(`fq=${encodeURIComponent('doc_type:compound')}`); + }); + + it('leaves legacy compound batches unfiltered', async () => { + const api = await loadBiochemApi(); + const fetchMock = mockFetch({ compounds: false }); + await api.getCompoundsByIds(['cpd00001']); + expect(dataUrl(fetchMock)).not.toContain('fq='); + }); +}); diff --git a/tests/unit/api/biochemThermo.test.ts b/tests/unit/api/biochemThermo.test.ts index 9ecda569..3fcbc9ef 100644 --- a/tests/unit/api/biochemThermo.test.ts +++ b/tests/unit/api/biochemThermo.test.ts @@ -97,7 +97,9 @@ describe('getReactionById / getCompoundById thermodynamics', () => { const dataCall = fetchMock.mock.calls.find(([input]) => !String(input).includes('rows=0')); const dataUrl = String(dataCall?.[0]); expect(dataUrl).toContain(`fq=${encodeURIComponent('doc_type:reaction')}`); - expect(dataUrl).toContain(`fl=${encodeURIComponent('*,[child childFilter=doc_type:thermodynamics]')}`); + expect(dataUrl).toContain(`fl=${encodeURIComponent('*,[child childFilter="doc_type:thermodynamics OR doc_type:stoichiometry" limit=200]')}`); + expect(dataUrl).toContain('doc_type%3Astoichiometry'); + expect(dataUrl).toContain('limit%3D200'); }); it('drops malformed children and coerces array-wrapped/absent numeric values', async () => { From f4d950766fd1cafb2ea225bcbfbd6f7782e973ca Mon Sep 17 00:00:00 2001 From: VibhavSetlur Date: Thu, 27 Aug 2026 12:35:15 -0500 Subject: [PATCH 2/2] docs(changelog): keep unreleased entries in their own section --- CHANGELOG.md | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b835a619..0065c8ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,15 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] - TBD -## [3.4.1] - 2026-08-27 - -### Fixed -- Reaction quick-search no longer queries Solr 9 nested stoichiometry child paths as parent fields, and reaction details now return normalized stoichiometry participants. -- Solr 9 compound batch and reverse reaction lookups now restrict results to parent documents. - ---- - - ### Removed - Unused tracked `.gsd/` planning scaffolding, `.clauderules`, and orphan `gsd-opencode` submodule gitlink. - Dead GSD references from `.gitignore`, `INDEX.md`, `README.md`, `docs/DEVELOPER_GUIDE.md`, `eslint.config.mjs`, and `vitest.config.ts`. @@ -30,6 +21,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- +## [3.4.1] - 2026-08-27 + +### Fixed +- Reaction quick-search no longer queries Solr 9 nested stoichiometry child paths as parent fields, and reaction details now return normalized stoichiometry participants. +- Solr 9 compound batch and reverse reaction lookups now restrict results to parent documents. + +--- + ## [3.4.0] - 2026-08-21 ### Added