Skip to content

Reference source · 0.1.0-dev.11

local-terminology.ts

src/local-terminology.ts · Download original file

import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { fingerprint,jsonBytes,requireThat } from './core.ts';
import type { ValidationIssue } from './external-fhir.ts';
type System={version:string;codes:Map<string,string[]>};
// Closed local catalogue. Unsupported compose features are rejected at startup, never treated as success.
export class LocalTerminology {
 readonly directory:string;readonly binding:string;private systems=new Map<string,System>();private sets=new Map<string,Set<string>>();private bindings:any[];
 constructor(directory:string){this.directory=directory;const manifest=JSON.parse(readFileSync(join(directory,'manifest.json'),'utf8'));const files=manifest.files.map((name:string)=>{requireThat(/^[A-Za-z0-9.-]+\.json$/.test(name),'invalid_terminology_catalogue');return JSON.parse(readFileSync(join(directory,'definitions',name),'utf8'));});this.binding=fingerprint(jsonBytes({manifest,files}));this.bindings=manifest.bindings;
  for(const cs of files.filter((v:any)=>v.resourceType==='CodeSystem')){requireThat(cs.content==='complete'&&cs.caseSensitive===true&&typeof cs.version==='string'&&!this.systems.has(cs.url),'unsupported_terminology_catalogue');const codes=new Map<string,string[]>();function walk(items:any[],parent?:string){for(const c of items){requireThat(typeof c.code==='string'&&!codes.has(c.code),'invalid_terminology_catalogue');codes.set(c.code,[...(parent?[parent]:[]),...(c.property??[]).filter((p:any)=>p.code==='subsumedBy').map((p:any)=>p.valueCode)]);if(c.concept)walk(c.concept,c.code);}}walk(cs.concept??[]);this.systems.set(cs.url,{version:cs.version,codes});}
  for(const vs of files.filter((v:any)=>v.resourceType==='ValueSet')){requireThat(vs.compose&&!vs.compose.inactive&&!vs.expansion&&!this.sets.has(vs.url),'unsupported_terminology_catalogue');const selected=new Set<string>();for(const group of ['include','exclude'])for(const part of vs.compose[group]??[]){requireThat(!part.valueSet&&typeof part.system==='string','unsupported_terminology_catalogue');const system=this.systems.get(part.system);requireThat(system&&(!part.version||part.version===system.version),'unsupported_terminology_catalogue');let codes=[...system!.codes.keys()];if(part.concept){requireThat(!part.filter,'unsupported_terminology_catalogue');codes=part.concept.map((c:any)=>c.code);requireThat(codes.every((c:string)=>system!.codes.has(c)),'invalid_terminology_catalogue');}for(const filter of part.filter??[]){requireThat(filter.property==='concept'&&filter.op==='is-a'&&system!.codes.has(filter.value),'unsupported_terminology_catalogue');const isA=(code:string,seen=new Set<string>()):boolean=>{if(code===filter.value)return true;if(seen.has(code))return false;seen.add(code);return (system!.codes.get(code)??[]).some(p=>isA(p,seen));};codes=codes.filter(c=>isA(c));}for(const code of codes){const key=`${part.system}|${code}`;if(group==='include')selected.add(key);else selected.delete(key);}}this.sets.set(vs.url,selected);}
  requireThat(Array.isArray(this.bindings)&&this.bindings.every(b=>this.sets.has(b.value_set)),'invalid_terminology_catalogue');
 }
 verify():void{requireThat(new LocalTerminology(this.directory).binding===this.binding,'terminology_catalogue_changed');}
 check(resource:Record<string,any>):ValidationIssue[]{const issues:ValidationIssue[]=[];const issue=(reason:string,path:string)=>issues.push({severity:'error',code:({'terminology-unavailable':'not-supported','coded-concept-required':'required'} as Record<string,string>)[reason]??'code-invalid',details:{text:reason},expression:[path]});
  const check=(coding:any,path:string)=>{const system=this.systems.get(coding?.system);if(!system||coding.version&&coding.version!==system.version)issue('terminology-unavailable',path);else if(typeof coding.code!=='string'||!system.codes.has(coding.code))issue('code-invalid',path);};
  function walk(value:any,path:string){if(!value||typeof value!=='object')return;if(Array.isArray(value)){value.forEach((v,i)=>walk(v,`${path}[${i}]`));return;}for(const [key,v]of Object.entries(value)){if(key==='coding'&&Array.isArray(v))v.forEach((c,i)=>check(c,`${path}.coding[${i}]`));else if(key==='valueCoding'||key==='class'&&resource.resourceType==='Encounter')check(v,`${path}.${key}`);else walk(v,`${path}.${key}`);}}
  walk(resource,resource.resourceType);
  const primary={Condition:'code',AllergyIntolerance:'code',MedicationStatement:'medicationCodeableConcept',Procedure:'code',Immunization:'vaccineCode'}[resource.resourceType as string];if(primary&&!resource[primary]?.coding?.length)issue('coded-concept-required',`${resource.resourceType}.${primary}`);
  for(const b of this.bindings.filter(b=>b.resource===resource.resourceType)){const value=resource[b.field];if(value===undefined)continue;const codings=b.primitive?[{system:b.system,code:value}]:b.coding?[value]:value.coding??[];if(!codings.some((c:any)=>this.sets.get(b.value_set)!.has(`${c.system}|${c.code}`)))issue('value-set-invalid',`${resource.resourceType}.${b.field}`);}
  return issues;
 }
}