diff --git a/tx/ocl/cs-ocl.cjs b/tx/ocl/cs-ocl.cjs index 2d3e9750..d71b984f 100644 --- a/tx/ocl/cs-ocl.cjs +++ b/tx/ocl/cs-ocl.cjs @@ -735,6 +735,17 @@ class OCLSourceCodeSystemProvider extends CodeSystemProvider { return { context: this.conceptCache.get(code), message: null }; } + // OCL concept IDs may differ in case from the FHIR code (e.g. "y" vs "Y"). + // Try a case-insensitive cache lookup before hitting the network. + const codeLower = code.toLowerCase(); + for (const [key, value] of this.conceptCache.entries()) { + if (key.toLowerCase() === codeLower) { + // Cache under the requested case as well so future lookups are O(1). + this.conceptCache.set(code, value); + return { context: value, message: null }; + } + } + if (this.scheduleBackgroundLoad) { this.scheduleBackgroundLoad('lookup-miss'); } @@ -1032,23 +1043,26 @@ class OCLSourceCodeSystemProvider extends CodeSystemProvider { this.scheduleBackgroundLoad('concept-miss'); } - const url = this.#buildConceptUrl(code); const pending = (async () => { - let response; - try { - response = await this.httpClient.get(url, { params: { verbose: true } }); - } catch (error) { - // Missing concept should be treated as not-found, not as an internal server failure. - if (error && error.response && error.response.status === 404) { - return null; - } - throw error; + const concept = await this.#fetchConceptByCode(code); + if (concept) { + return concept; } - const concept = this.#toConceptContext(response.data); - if (concept && concept.code) { - this.conceptCache.set(concept.code, concept); + // OCL concept IDs may differ in case from the FHIR code (e.g. "y" vs "Y"). + // Try common case alternatives before giving up. + const lower = code.toLowerCase(); + const upper = code.toUpperCase(); + for (const alt of [lower, upper]) { + if (alt !== code) { + const altConcept = await this.#fetchConceptByCode(alt); + if (altConcept) { + // Cache under the originally requested code so future lookups hit directly. + this.conceptCache.set(code, altConcept); + return altConcept; + } + } } - return concept; + return null; })(); this.pendingConceptRequests.set(pendingKey, pending); @@ -1059,6 +1073,24 @@ class OCLSourceCodeSystemProvider extends CodeSystemProvider { } } + async #fetchConceptByCode(code) { + const url = this.#buildConceptUrl(code); + let response; + try { + response = await this.httpClient.get(url, { params: { verbose: true } }); + } catch (error) { + if (error && error.response && error.response.status === 404) { + return null; + } + throw error; + } + const concept = this.#toConceptContext(response.data); + if (concept && concept.code) { + this.conceptCache.set(concept.code, concept); + } + return concept; + } + async #allConceptContexts() { const concepts = new Map(); diff --git a/tx/ocl/vs-ocl.cjs b/tx/ocl/vs-ocl.cjs index d056a6b1..72057332 100644 --- a/tx/ocl/vs-ocl.cjs +++ b/tx/ocl/vs-ocl.cjs @@ -389,20 +389,26 @@ class OCLValueSetProvider extends AbstractValueSetProvider { } #indexValueSet(vs) { - const existing = this.valueSetMap.get(vs.url) - || (vs.version ? this.valueSetMap.get(`${vs.url}|${vs.version}`) : null) - || this._idMap.get(vs.id) - || null; - - // Só indexa se não existe ou se for o mesmo objeto - if (!existing || existing === vs) { - this.valueSetMap.set(vs.url, vs); - if (vs.version) { - this.valueSetMap.set(`${vs.url}|${vs.version}`, vs); - } - this.valueSetMap.set(vs.id, vs); - this._idMap.set(vs.id, vs); + const existing = this.valueSetMap.get(vs.url) || null; + + // When fresh discovery metadata replaces a cold-cached entry, carry over + // the enumerated compose so the expand engine doesn't fall back to + // "include whole CodeSystem". The background expansion will eventually + // refresh it with up-to-date collection contents. + if (existing && existing !== vs + && Array.isArray(existing.jsonObj?.compose?.include) + && existing.jsonObj.compose.include.some(inc => Array.isArray(inc.concept) && inc.concept.length > 0) + && (!vs.jsonObj.compose || !Array.isArray(vs.jsonObj.compose.include) || vs.jsonObj.compose.include.length === 0) + ) { + vs.jsonObj.compose = existing.jsonObj.compose; + } + + this.valueSetMap.set(vs.url, vs); + if (vs.version) { + this.valueSetMap.set(`${vs.url}|${vs.version}`, vs); } + this.valueSetMap.set(vs.id, vs); + this._idMap.set(vs.id, vs); } #toValueSet(collection) { @@ -568,6 +574,17 @@ class OCLValueSetProvider extends AbstractValueSetProvider { ? vs.jsonObj.compose.include : []; + // If the compose already has enumerated concepts (from background expansion), + // it is the authoritative representation of the collection contents — don't + // overwrite it with system-only entries that would cause the expand engine + // to include ALL concepts from the CodeSystem. + const hasEnumeratedConcepts = existingInclude.some( + inc => Array.isArray(inc.concept) && inc.concept.length > 0 + ); + if (hasEnumeratedConcepts) { + return; + } + // Always normalize existing compose entries first because discovery metadata // can carry non-canonical preferred_source values. const include = this.#normalizeComposeIncludes(existingInclude); @@ -972,9 +989,12 @@ class OCLValueSetProvider extends AbstractValueSetProvider { return; } - const cached = this.backgroundExpansionCache.get(cacheKey); + let cached = this.backgroundExpansionCache.get(cacheKey); + let invalidated = false; if (cached && !this.#isCachedExpansionValid(vs, cached)) { this.backgroundExpansionCache.delete(cacheKey); + cached = null; + invalidated = true; } // Already have a cached compose ready @@ -982,23 +1002,22 @@ class OCLValueSetProvider extends AbstractValueSetProvider { return; } - const cacheFilePath = getCacheFilePath(CACHE_VS_DIR, vs.url, vs.version || null, paramsKey); - const cacheAgeFromFileMs = getColdCacheAgeMs(cacheFilePath); - const persistedCache = this.backgroundExpansionCache.get(cacheKey); - const cacheAgeFromMetadataMs = Number.isFinite(persistedCache?.createdAt) - ? Math.max(0, Date.now() - persistedCache.createdAt) - : null; - - // Treat cache as fresh when either file mtime or persisted timestamp is recent. - const freshnessCandidates = [cacheAgeFromFileMs, cacheAgeFromMetadataMs].filter(age => age != null); - const freshestCacheAgeMs = freshnessCandidates.length > 0 ? Math.min(...freshnessCandidates) : null; - if (freshestCacheAgeMs != null && freshestCacheAgeMs <= COLD_CACHE_FRESHNESS_MS) { - const freshnessSource = cacheAgeFromFileMs != null && cacheAgeFromMetadataMs != null - ? 'file+metadata' - : cacheAgeFromFileMs != null - ? 'file' - : 'metadata'; - return; + // Skip freshness check when cache was just invalidated (VS metadata changed + // on the server) — the cold cache file is stale even if recently written. + if (!invalidated) { + const cacheFilePath = getCacheFilePath(CACHE_VS_DIR, vs.url, vs.version || null, paramsKey); + const cacheAgeFromFileMs = getColdCacheAgeMs(cacheFilePath); + const persistedCache = this.backgroundExpansionCache.get(cacheKey); + const cacheAgeFromMetadataMs = Number.isFinite(persistedCache?.createdAt) + ? Math.max(0, Date.now() - persistedCache.createdAt) + : null; + + // Treat cache as fresh when either file mtime or persisted timestamp is recent. + const freshnessCandidates = [cacheAgeFromFileMs, cacheAgeFromMetadataMs].filter(age => age != null); + const freshestCacheAgeMs = freshnessCandidates.length > 0 ? Math.min(...freshnessCandidates) : null; + if (freshestCacheAgeMs != null && freshestCacheAgeMs <= COLD_CACHE_FRESHNESS_MS) { + return; + } } const jobKey = `vs:${cacheKey}`; @@ -1121,7 +1140,11 @@ class OCLValueSetProvider extends AbstractValueSetProvider { if (!systemConcepts.has(entry.system)) { systemConcepts.set(entry.system, []); } - systemConcepts.get(entry.system).push(entry.code); + const concept = { code: entry.code }; + if (Array.isArray(entry.designation) && entry.designation.length > 0) { + concept.designation = entry.designation; + } + systemConcepts.get(entry.system).push(concept); totalCount++; } if (progressState) { @@ -1138,9 +1161,9 @@ class OCLValueSetProvider extends AbstractValueSetProvider { } return { - include: Array.from(systemConcepts.entries()).map(([system, codes]) => ({ + include: Array.from(systemConcepts.entries()).map(([system, concepts]) => ({ system, - concept: codes.map(code => ({ code })) + concept: concepts })) }; }