fhirsmith 0.12.0 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CHANGELOG.md +323 -4
  2. package/README.md +1 -1
  3. package/config-template.json +13 -0
  4. package/library/languages.js +191 -34
  5. package/library/path-safety.js +51 -0
  6. package/library/request-token.js +56 -0
  7. package/package.json +2 -2
  8. package/packages/packages.js +17 -118
  9. package/publisher/publisher.js +60 -30
  10. package/publisher/validation.js +15 -3
  11. package/server.js +12 -3
  12. package/translations/Messages.properties +1 -0
  13. package/tx/README.md +21 -0
  14. package/tx/cs/cs-api.js +30 -1
  15. package/tx/cs/cs-cs.js +14 -1
  16. package/tx/cs/cs-icd11.js +844 -0
  17. package/tx/cs/cs-lang.js +331 -85
  18. package/tx/cs/cs-loinc.js +45 -4
  19. package/tx/cs/cs-mimetypes.js +492 -21
  20. package/tx/cs/cs-snomed.js +38 -4
  21. package/tx/cs/cs-ucum.js +33 -3
  22. package/tx/cs/icd11-expressions.js +243 -0
  23. package/tx/importers/export-icd11-codesystem.js +823 -0
  24. package/tx/importers/icd11-schema.md +285 -0
  25. package/tx/importers/import-icd11.js +861 -0
  26. package/tx/importers/import-loinc.module.js +14 -7
  27. package/tx/importers/import-sct.module.js +10 -1
  28. package/tx/library/codesystem.js +21 -27
  29. package/tx/library/nesting-limit.js +79 -0
  30. package/tx/library/operation-outcome.js +97 -3
  31. package/tx/library/valueset.js +16 -20
  32. package/tx/library.js +23 -1
  33. package/tx/ocl/cache/cache-utils.cjs +5 -3
  34. package/tx/ocl/cs-ocl.cjs +73 -17
  35. package/tx/ocl/jobs/background-queue.cjs +6 -3
  36. package/tx/ocl/mappers/concept-mapper.cjs +9 -1
  37. package/tx/ocl/shared/patches.cjs +48 -1
  38. package/tx/ocl/vs-ocl.cjs +15 -13
  39. package/tx/provider.js +1 -1
  40. package/tx/sct/expressions.js +194 -17
  41. package/tx/tx.fhir.org.yml +2 -1
  42. package/tx/tx.js +15 -42
  43. package/tx/workers/batch-validate.js +6 -16
  44. package/tx/workers/batch.js +16 -28
  45. package/tx/workers/cache-control.js +6 -12
  46. package/tx/workers/closure.js +3 -8
  47. package/tx/workers/compare.js +6 -34
  48. package/tx/workers/expand.js +36 -45
  49. package/tx/workers/lookup.js +21 -39
  50. package/tx/workers/read.js +6 -40
  51. package/tx/workers/search.js +2 -8
  52. package/tx/workers/subsumes.js +58 -41
  53. package/tx/workers/translate.js +11 -17
  54. package/tx/workers/validate.js +76 -77
  55. package/tx/workers/worker.js +70 -1
  56. package/tx/xml/xml-base.js +243 -76
  57. package/tx/xversion/xv-valueset.js +39 -1
@@ -274,17 +274,23 @@ class LoincModule extends BaseTerminologyModule {
274
274
  throw new Error(`Source directory not found: ${sourceDir}`);
275
275
  }
276
276
 
277
+ // The accessory files are required, not optional. Without them the import silently
278
+ // succeeds and the server then answers questions wrongly rather than saying it cannot:
279
+ // no ComponentHierarchyBySystem means an empty closure and $subsumes replying
280
+ // 'not-subsumed' to everything, which is indistinguishable from a real answer.
277
281
  const requiredFiles = [
278
282
  'LoincTable/Loinc.csv',
279
- 'AccessoryFiles/PartFile/Part.csv'
280
- ];
281
-
282
- const optionalFiles = [
283
+ 'AccessoryFiles/PartFile/Part.csv',
283
284
  'AccessoryFiles/ConsumerName/ConsumerName.csv',
284
285
  'AccessoryFiles/AnswerFile/AnswerList.csv',
285
286
  'AccessoryFiles/PartFile/LoincPartLink_Primary.csv',
286
287
  'AccessoryFiles/AnswerFile/LoincAnswerListLink.csv',
287
- 'AccessoryFiles/ComponentHierarchyBySystem/ComponentHierarchyBySystem.csv',
288
+ 'AccessoryFiles/ComponentHierarchyBySystem/ComponentHierarchyBySystem.csv'
289
+ ];
290
+
291
+ // Translations are a different case: a main-only import is a legitimate thing to want,
292
+ // and their absence costs designations, not correctness
293
+ const optionalFiles = [
288
294
  'AccessoryFiles/LinguisticVariants/LinguisticVariants.csv'
289
295
  ];
290
296
 
@@ -1214,8 +1220,9 @@ class LoincDataMigrator {
1214
1220
  async processHierarchy(db, sourceDir, step, options) {
1215
1221
  const filePath = path.join(sourceDir, 'AccessoryFiles/ComponentHierarchyBySystem/ComponentHierarchyBySystem.csv');
1216
1222
  if (!fs.existsSync(filePath)) {
1217
- if (options.verbose) console.warn(`Hierarchy file not found: ${filePath}`);
1218
- return;
1223
+ // Validation should have caught this already; if we get here, fail rather than build a
1224
+ // cache with an empty closure that answers subsumption questions with a confident no
1225
+ throw new Error(`Required file missing: ${filePath}`);
1219
1226
  }
1220
1227
 
1221
1228
  if (options.verbose) console.log('Processing Hierarchy...');
@@ -13,6 +13,9 @@ const {
13
13
  } = require('../sct/structures');
14
14
  const {SnomedExpressionServices} = require("../sct/expressions");
15
15
 
16
+ // definitionStatusId values from the RF2 concept file
17
+ const SNOMED_DEFINITION_STATUS_PRIMITIVE = 900000000000074008n;
18
+
16
19
  class SnomedModule extends BaseTerminologyModule {
17
20
 
18
21
  constructor() {
@@ -1342,7 +1345,13 @@ class SnomedImporter {
1342
1345
 
1343
1346
  for (let i = 0; i < this.conceptList.length; i++) {
1344
1347
  const concept = this.conceptList[i];
1345
- const flags = concept.active ? 0 : 1;
1348
+ // Bits 0-3 are the status (MASK_CONCEPT_STATUS); bit 4 (MASK_CONCEPT_PRIMITIVE) says the
1349
+ // concept is primitive. Without the primitive bit every concept looks fully defined, and
1350
+ // normal form generation then runs for concepts that have no definition to expand.
1351
+ let flags = concept.active ? 0 : 1;
1352
+ if (concept.definitionStatusId === SNOMED_DEFINITION_STATUS_PRIMITIVE) {
1353
+ flags |= SnomedConceptList.MASK_CONCEPT_PRIMITIVE;
1354
+ }
1346
1355
  const effectiveTime = this.convertDateToSnomedDate(concept.effectiveTime);
1347
1356
 
1348
1357
  concept.index = this.concepts.addConcept(concept.id, effectiveTime, flags);
@@ -3,6 +3,7 @@ const {CanonicalResource} = require("./canonical-resource");
3
3
  const {codeSystemFromR5, codeSystemToR5} = require("../xversion/xv-codesystem");
4
4
  const {getValuePrimitive} = require("../../library/utilities");
5
5
  const {VersionUtilities} = require("../../library/version-utilities");
6
+ const {countNested} = require("./nesting-limit");
6
7
 
7
8
  const CodeSystemContentMode = Object.freeze({
8
9
  Complete: 'complete',
@@ -33,18 +34,30 @@ class CodeSystem extends CanonicalResource {
33
34
  super(jsonObj, fhirVersion);
34
35
  // Convert to R5 format internally (modifies input for performance)
35
36
  this.jsonObj = codeSystemToR5(this.jsonObj, fhirVersion);
36
- if (!noMaps) {
37
- try {
37
+ try {
38
+ // Bound the nesting before anything walks the concept tree recursively - see
39
+ // nesting-limit.js. It has to precede validate(), which recurses itself, and it
40
+ // runs on the noMaps path too: validate() is skipped there, but the count below
41
+ // and every later consumer still walk the tree. The count is a by-product, so
42
+ // callers (e.g. the resource cache) get a cheap O(1) sense of the resource size
43
+ // without a second traversal.
44
+ // Optional chaining because this runs ahead of validate()'s "expected object"
45
+ // guard, and a null body must still get that message rather than a TypeError.
46
+ this._conceptCount = countNested(this.jsonObj?.concept, 'concept', 'Invalid CodeSystem: concept');
47
+ if (!noMaps) {
38
48
  this.validate();
39
- } catch (e) {
40
- const id = this.jsonObj?.url ? `${this.jsonObj.url}|${this.jsonObj.version || ''}` : this.jsonObj?.name || 'unknown';
41
- throw new Error(`${e.message} (in ${id})`);
42
49
  }
50
+ } catch (e) {
51
+ const id = this.jsonObj?.url ? `${this.jsonObj.url}|${this.jsonObj.version || ''}` : this.jsonObj?.name || 'unknown';
52
+ const wrapped = new Error(`${e.message} (in ${id})`);
53
+ // Don't let the rewrap swallow the HTTP shape the thrower asked for.
54
+ if (e.statusCode) { wrapped.statusCode = e.statusCode; }
55
+ if (e.issueCode) { wrapped.issueCode = e.issueCode; }
56
+ throw wrapped;
57
+ }
58
+ if (!noMaps) {
43
59
  this.buildMaps();
44
60
  }
45
- // Precalculated at construction so callers (e.g. the resource cache) have a
46
- // cheap O(1) sense of how large this resource is.
47
- this._conceptCount = CodeSystem._countConcepts(this.jsonObj.concept);
48
61
  }
49
62
 
50
63
  /**
@@ -56,25 +69,6 @@ class CodeSystem extends CanonicalResource {
56
69
  return this._conceptCount;
57
70
  }
58
71
 
59
- /**
60
- * Recursively count concepts (including nested `concept` children).
61
- * @param {Array} concepts
62
- * @returns {number}
63
- */
64
- static _countConcepts(concepts) {
65
- if (!Array.isArray(concepts)) {
66
- return 0;
67
- }
68
- let n = 0;
69
- for (const c of concepts) {
70
- n++;
71
- if (c && c.concept) {
72
- n += CodeSystem._countConcepts(c.concept);
73
- }
74
- }
75
- return n;
76
- }
77
-
78
72
  /**
79
73
  * Map of code to concept object for fast lookup
80
74
  * @type {Map<string, Object>}
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Bounds on how deeply a resource may nest itself.
3
+ *
4
+ * FHIR lets both CodeSystem.concept and ValueSet.expansion.contains nest without
5
+ * limit, and both are walked recursively all over this codebase (the expansion map,
6
+ * the concept count, the renderer, the expander's index, the R5->R4 converter, ...).
7
+ * A hostile resource of a few hundred KB can therefore blow the JS stack, and even
8
+ * if every one of those walkers were made iterative, `JSON.stringify` -- which we do
9
+ * not control -- overflows at a nesting depth of roughly 1800 on node 22.
10
+ *
11
+ * Rather than harden each walker in turn and hope the next one added remembers, we
12
+ * bound the nesting once, when the resource is constructed. Everything downstream is
13
+ * then safe by construction, including code that does not exist yet.
14
+ *
15
+ * The limit is deliberately far below where anything actually breaks. Real nesting
16
+ * mirrors the hierarchy of a code system -- SNOMED CT is about 30 deep, LOINC parts
17
+ * about 10, ICD-10 about 5 -- so 100 is well past any legitimate resource while
18
+ * leaving more than an order of magnitude of headroom against the stack.
19
+ *
20
+ * @module tx/library/nesting-limit
21
+ */
22
+
23
+ /**
24
+ * Maximum nesting depth allowed in a resource's self-recursive structure.
25
+ * @type {number}
26
+ */
27
+ const MAX_NESTING_DEPTH = 100;
28
+
29
+ /**
30
+ * Walks a self-nesting FHIR structure iteratively, counting its entries and
31
+ * enforcing {@link MAX_NESTING_DEPTH}.
32
+ *
33
+ * The walk uses an explicit stack, so it costs no JS stack depth of its own and can
34
+ * safely be pointed at a tree deep enough to overflow a recursive walker. It is the
35
+ * one traversal that has to survive hostile input; every other walker in the codebase
36
+ * runs after this one has passed.
37
+ *
38
+ * Non-array input counts as zero, matching the tolerant behaviour of the recursive
39
+ * counters this replaces - structural type checking is the caller's business.
40
+ *
41
+ * @param {Array} items - Top-level array of the nesting structure
42
+ * @param {string} childProperty - Name of the property holding nested children ('concept', 'contains')
43
+ * @param {string} path - Path used in the error message ('CodeSystem.concept')
44
+ * @returns {number} Total number of entries, nested ones included
45
+ * @throws {Error} If nesting exceeds MAX_NESTING_DEPTH, tagged statusCode 400 / issueCode 'structure'
46
+ */
47
+ function countNested(items, childProperty, path) {
48
+ if (!Array.isArray(items)) {
49
+ return 0;
50
+ }
51
+
52
+ let count = 0;
53
+ // Each frame is a list to walk plus the depth at which its entries sit.
54
+ const stack = [{ list: items, depth: 1 }];
55
+
56
+ while (stack.length > 0) {
57
+ const { list, depth } = stack.pop();
58
+ if (depth > MAX_NESTING_DEPTH) {
59
+ // The worker error handlers honour statusCode/issueCode when they are present on
60
+ // a plain Error, so tag it: this is a bad resource from the client, not a server
61
+ // fault, and it should come back as a 400 rather than the default 500/exception.
62
+ const e = new Error(
63
+ `${path} is nested more than ${MAX_NESTING_DEPTH} levels deep, which is not supported`);
64
+ e.statusCode = 400;
65
+ e.issueCode = 'structure';
66
+ throw e;
67
+ }
68
+ for (const item of list) {
69
+ count++;
70
+ if (item && Array.isArray(item[childProperty]) && item[childProperty].length > 0) {
71
+ stack.push({ list: item[childProperty], depth: depth + 1 });
72
+ }
73
+ }
74
+ }
75
+
76
+ return count;
77
+ }
78
+
79
+ module.exports = { MAX_NESTING_DEPTH, countNested };
@@ -1,5 +1,80 @@
1
1
  const {validateParameter} = require("../../library/utilities");
2
2
 
3
+ const TX_ISSUE_TYPE_SYSTEM = "http://hl7.org/fhir/tools/CodeSystem/tx-issue-type";
4
+
5
+ /**
6
+ * Every issue this server emits carries a tx-issue-type coding in details.coding, and the
7
+ * human readable account of the problem in details.text. Nothing a client needs is allowed
8
+ * to live *only* in diagnostics: the test harness strips diagnostics outright, so anything
9
+ * that lives there alone is, by construction, invisible to a test. diagnostics itself is
10
+ * still sent -- it is the right place for server-specific detail, and dropping it would
11
+ * take away something useful to get a property no test could have relied on anyway.
12
+ *
13
+ * Most issues name their tx-issue-type explicitly. This map is the backstop for the ones
14
+ * that do not, so that "always has a coding" holds without every one of the ~170 Issue
15
+ * construction sites having to be revisited at once. It is keyed on the FHIR issue-type,
16
+ * which is the coarser of the two vocabularies, so an explicit code is always better.
17
+ */
18
+ const DEFAULT_TX_ISSUE_TYPE = {
19
+ 'not-found': 'not-found',
20
+ 'code-invalid': 'invalid-code',
21
+ 'invalid': 'invalid-data',
22
+ 'structure': 'invalid-data',
23
+ 'required': 'invalid-data',
24
+ 'value': 'invalid-data',
25
+ 'processing': 'invalid-data',
26
+ 'exception': 'invalid-data',
27
+ 'unknown': 'invalid-data',
28
+ 'conflict': 'invalid-data',
29
+ 'business-rule': 'business-rule',
30
+ 'too-costly': 'too-costly',
31
+ 'not-supported': 'not-supported',
32
+ };
33
+
34
+ /**
35
+ * The tx-issue-type for an issue that did not name one. Informational issues that are not
36
+ * about a code or a value set -- the server banner, say -- are not tx issues at all and get
37
+ * no coding; everything else does.
38
+ */
39
+ function defaultTxIssueType(code, severity) {
40
+ if (severity === 'information' && (code === 'informational' || !code)) {
41
+ return null;
42
+ }
43
+ return DEFAULT_TX_ISSUE_TYPE[code] || 'invalid-data';
44
+ }
45
+
46
+ /**
47
+ * Build a single issue from loose parts. Prefer throwing an Issue -- this is for the
48
+ * handful of places that build an outcome directly, so that they cannot drift away from
49
+ * the shape Issue.asIssue() produces.
50
+ *
51
+ * `diagnostics` still goes out on the wire. The rule is that nothing a client needs may
52
+ * live *only* there, because the test harness strips it before it compares -- not that the
53
+ * server should stop saying anything. Callers with real internal detail to add (a stack
54
+ * location, a request id, what the server was doing when it gave up) pass it; the rest get
55
+ * the message, which is what this server has always sent.
56
+ */
57
+ function buildIssue(severity, code, message, txIssueType = null, diagnostics = undefined) {
58
+ const res = { severity, code, details: { text: message } };
59
+ const tx = txIssueType || defaultTxIssueType(code, severity);
60
+ if (tx) {
61
+ res.details.coding = [{ system: TX_ISSUE_TYPE_SYSTEM, code: tx }];
62
+ }
63
+ const diag = diagnostics === undefined ? message : diagnostics;
64
+ if (diag) {
65
+ res.diagnostics = diag;
66
+ }
67
+ return res;
68
+ }
69
+
70
+ /** Build a one issue OperationOutcome from loose parts. */
71
+ function buildOperationOutcome(severity, code, message, txIssueType = null, diagnostics = undefined) {
72
+ return {
73
+ resourceType: 'OperationOutcome',
74
+ issue: [buildIssue(severity, code, message, txIssueType, diagnostics)],
75
+ };
76
+ }
77
+
3
78
  class Issue extends Error {
4
79
  level;
5
80
  cause;
@@ -33,8 +108,9 @@ class Issue extends Error {
33
108
  if (this.path) {
34
109
  res.expression = [this.path]
35
110
  }
36
- if (this.issueCode) {
37
- res.details.coding = [{ system: "http://hl7.org/fhir/tools/CodeSystem/tx-issue-type", code : this.issueCode }];
111
+ const issueCode = this.issueCode || defaultTxIssueType(this.cause, this.level);
112
+ if (issueCode) {
113
+ res.details.coding = [{ system: TX_ISSUE_TYPE_SYSTEM, code : issueCode }];
38
114
  }
39
115
  if (this.msgId) {
40
116
  res.extension = [{ url: "http://hl7.org/fhir/StructureDefinition/operationoutcome-message-id", valueString: this.msgId }];
@@ -129,4 +205,22 @@ class OperationOutcome {
129
205
  }
130
206
  }
131
207
 
132
- module.exports = { OperationOutcome, Issue };
208
+ /**
209
+ * Turn a thrown error into an OperationOutcome. Two conventions meet here, and this is the
210
+ * only place that has to know about both: an Issue carries the FHIR issue type in `cause`
211
+ * and the tx-issue-type in `issueCode`, while a plain Error tagged by whatever threw it
212
+ * carries the FHIR issue type in `issueCode` and the tx-issue-type in `txIssueType`. Either
213
+ * way the result has details.text and a tx-issue-type coding.
214
+ */
215
+ function outcomeFromError(error) {
216
+ if (error instanceof Issue) {
217
+ const oo = new OperationOutcome();
218
+ oo.addIssue(error);
219
+ return oo.jsonObj;
220
+ }
221
+ return buildOperationOutcome('error', error.issueCode || 'exception',
222
+ error.message || String(error), error.txIssueType || null, error.diagnostics);
223
+ }
224
+
225
+ module.exports = { OperationOutcome, Issue, buildIssue, buildOperationOutcome,
226
+ outcomeFromError, defaultTxIssueType, TX_ISSUE_TYPE_SYSTEM };
@@ -1,5 +1,6 @@
1
1
  const {CanonicalResource} = require("./canonical-resource");
2
2
  const {valueSetToR5, valueSetFromR5} = require("../xversion/xv-valueset");
3
+ const {countNested} = require("./nesting-limit");
3
4
 
4
5
  /**
5
6
  * Represents a FHIR ValueSet resource with version conversion support
@@ -27,7 +28,7 @@ class ValueSet extends CanonicalResource {
27
28
  this.buildMaps();
28
29
  // Precalculated at construction so callers (e.g. the resource cache) have a
29
30
  // cheap O(1) sense of how large this resource is.
30
- this._conceptCount = ValueSet._countConcepts(this.jsonObj);
31
+ this._conceptCount = ValueSet._countConcepts(this.jsonObj, this._containsCount);
31
32
  }
32
33
 
33
34
  /**
@@ -42,7 +43,7 @@ class ValueSet extends CanonicalResource {
42
43
  return this._conceptCount;
43
44
  }
44
45
 
45
- static _countConcepts(jsonObj) {
46
+ static _countConcepts(jsonObj, containsCount) {
46
47
  let n = 0;
47
48
  const compose = jsonObj && jsonObj.compose;
48
49
  if (compose) {
@@ -53,24 +54,7 @@ class ValueSet extends CanonicalResource {
53
54
  n += Array.isArray(exc.concept) ? exc.concept.length : 0;
54
55
  }
55
56
  }
56
- if (jsonObj && jsonObj.expansion) {
57
- n += ValueSet._countContains(jsonObj.expansion.contains);
58
- }
59
- return n;
60
- }
61
-
62
- static _countContains(items) {
63
- if (!Array.isArray(items)) {
64
- return 0;
65
- }
66
- let n = 0;
67
- for (const it of items) {
68
- n++;
69
- if (it && it.contains) {
70
- n += ValueSet._countContains(it.contains);
71
- }
72
- }
73
- return n;
57
+ return n + (containsCount || 0);
74
58
  }
75
59
 
76
60
  /**
@@ -79,6 +63,13 @@ class ValueSet extends CanonicalResource {
79
63
  */
80
64
  codeMap = new Map();
81
65
 
66
+ /**
67
+ * Number of entries in expansion.contains, nested ones included. Counted by the
68
+ * depth-bounding walk in validate(), so the tree is only traversed once.
69
+ * @type {number}
70
+ */
71
+ _containsCount = 0;
72
+
82
73
  /**
83
74
  * Static factory method for convenience
84
75
  * @param {string} jsonString - JSON string representation of ValueSet
@@ -158,6 +149,11 @@ class ValueSet extends CanonicalResource {
158
149
  if (this.jsonObj.expansion.contains && !Array.isArray(this.jsonObj.expansion.contains)) {
159
150
  throw new Error('Invalid ValueSet: expansion.contains must be an array if present');
160
151
  }
152
+ // Bound the nesting before anything walks it recursively - see nesting-limit.js.
153
+ // This has to happen before buildMaps(), which is itself a recursive walker, so
154
+ // it lives here rather than alongside the concept count in the constructor.
155
+ this._containsCount = countNested(this.jsonObj.expansion.contains, 'contains',
156
+ 'Invalid ValueSet: expansion.contains');
161
157
  }
162
158
  }
163
159
 
package/tx/library.js CHANGED
@@ -23,6 +23,7 @@ const {UniiServicesFactory} = require("./cs/cs-unii");
23
23
  const {SnomedServicesFactory} = require("./cs/cs-snomed");
24
24
  const {CPTServicesFactory} = require("./cs/cs-cpt");
25
25
  const {OMOPServicesFactory} = require("./cs/cs-omop");
26
+ const {ICD11ServicesFactory} = require("./cs/cs-icd11");
26
27
  const {PackageValueSetProvider} = require("./vs/vs-package");
27
28
  const {PackageConceptMapProvider} = require("./cm/cm-package");
28
29
  const {IETFLanguageCodeFactory} = require("./cs/cs-lang");
@@ -277,6 +278,10 @@ class Library {
277
278
  await this.loadOmop(details, isDefault, mode);
278
279
  break;
279
280
 
281
+ case 'icd11':
282
+ await this.loadIcd11(details, isDefault, mode);
283
+ break;
284
+
280
285
  case 'npm':
281
286
  await this.loadNpm(packageManager, details, isDefault, mode, false);
282
287
  break;
@@ -457,7 +462,7 @@ class Library {
457
462
  break;
458
463
  }
459
464
  case "mimetypes" : {
460
- const mime = new MimeTypeServicesFactory(this.i18n);
465
+ const mime = new MimeTypeServicesFactory(this.i18n, this.cacheFolder, this.log);
461
466
  await mime.load();
462
467
  this.registerProvider('internal', mime);
463
468
  break;
@@ -591,6 +596,23 @@ class Library {
591
596
  this.registerProvider(omopFN, omop, isDefault);
592
597
  }
593
598
 
599
+ /**
600
+ * ICD-11 is three code systems in one database - the MMS and ICF linearizations and the
601
+ * Foundation - so one source line registers a factory per system that the database
602
+ * actually contains. registerProvider is keyed on system(), so they cannot share one.
603
+ */
604
+ async loadIcd11(details, isDefault, mode) {
605
+ const icdFN = await this.getOrDownloadFile(details);
606
+ if (mode === "fetch" || mode === "npm") {
607
+ return;
608
+ }
609
+ for (const sys of await ICD11ServicesFactory.listSystems(icdFN)) {
610
+ const factory = new ICD11ServicesFactory(this.i18n, icdFN, sys.code);
611
+ await factory.load();
612
+ this.registerProvider(icdFN, factory, isDefault);
613
+ }
614
+ }
615
+
594
616
  /**
595
617
  * Returns true if the given url/version should be excluded from npm/url package loading.
596
618
  * Matches against the ignored list using either plain url or url#version.
@@ -1,5 +1,7 @@
1
1
  const fs = require('fs/promises');
2
2
  const fsSync = require('fs');
3
+ const Logger = require('../../../library/logger');
4
+ const oclLog = Logger.getInstance().child({ module: 'ocl' });
3
5
 
4
6
  async function ensureCacheDirectories(...dirs) {
5
7
  for (const dir of dirs) {
@@ -10,12 +12,12 @@ async function ensureCacheDirectories(...dirs) {
10
12
  try {
11
13
  await fs.mkdir(dir, { recursive: true });
12
14
  } catch (error) {
13
- console.error('[OCL] Failed to create cache directory:', dir, error.message);
15
+ oclLog.error(`Failed to create cache directory: ${dir} ${error.message}`);
14
16
  }
15
17
  }
16
18
  }
17
19
 
18
- function getColdCacheAgeMs(cacheFilePath, logPrefix = '[OCL]') {
20
+ function getColdCacheAgeMs(cacheFilePath) {
19
21
  try {
20
22
  const stats = fsSync.statSync(cacheFilePath);
21
23
  if (!stats || !Number.isFinite(stats.mtimeMs)) {
@@ -25,7 +27,7 @@ function getColdCacheAgeMs(cacheFilePath, logPrefix = '[OCL]') {
25
27
  return Math.max(0, Date.now() - stats.mtimeMs);
26
28
  } catch (error) {
27
29
  if (error && error.code !== 'ENOENT') {
28
- console.error(`${logPrefix} Failed to inspect cold cache file ${cacheFilePath}: ${error.message}`);
30
+ oclLog.error(`Failed to inspect cold cache file ${cacheFilePath}: ${error.message}`);
29
31
  }
30
32
  return null;
31
33
  }