Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 46 additions & 14 deletions tx/ocl/cs-ocl.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
Expand Down Expand Up @@ -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);
Expand All @@ -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();

Expand Down
91 changes: 57 additions & 34 deletions tx/ocl/vs-ocl.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -972,33 +989,35 @@ 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
if (cached && cached.compose) {
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}`;
Expand Down Expand Up @@ -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) {
Expand All @@ -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
}))
};
}
Expand Down
Loading