fhirsmith 0.9.6 → 0.9.7

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.
@@ -3,6 +3,27 @@ const {Extensions} = require("./extensions");
3
3
  const {div} = require("../../library/html");
4
4
  const {getValuePrimitive} = require("../../library/utilities");
5
5
  const {getValueName} = require("../../library/utilities");
6
+ const {InvalidError} = require("./errors");
7
+
8
+ // Valid FHIR value sets used when validating values before rendering, so that
9
+ // illegal input produces an error that names the offending value rather than
10
+ // being silently rendered or causing a generic crash downstream.
11
+ const VALID_FILTER_OPS = new Set([
12
+ '=', 'is-a', 'descendent-of', 'is-not-a', 'regex', 'in', 'not-in',
13
+ 'generalizes', 'child-of', 'descendent-leaf', 'exists'
14
+ ]);
15
+ const VALID_CONCEPTMAP_RELATIONSHIPS = new Set([
16
+ 'related-to', 'equivalent', 'source-is-narrower-than-target',
17
+ 'source-is-broader-than-target', 'not-related-to'
18
+ ]);
19
+ const VALID_CONCEPTMAP_EQUIVALENCES = new Set([
20
+ 'relatedto', 'equivalent', 'equal', 'wider', 'subsumes', 'narrower',
21
+ 'specializes', 'inexact', 'unmatched', 'disjoint'
22
+ ]);
23
+ const VALID_CODESYSTEM_CONTENT = new Set([
24
+ 'not-present', 'example', 'fragment', 'complete', 'supplement'
25
+ ]);
26
+ const VALID_PUBLICATION_STATUS = new Set(['draft', 'active', 'retired', 'unknown']);
6
27
 
7
28
  /**
8
29
  * @typedef {Object} TerminologyLinkResolver
@@ -239,10 +260,12 @@ class Renderer {
239
260
  }
240
261
 
241
262
  renderMetadataLastUpdated(res, tbl) {
242
- if (res.meta?.version) {
263
+ if (res.meta?.lastUpdated) {
264
+ this._requireValidDate(res.meta.lastUpdated, 'meta.lastUpdated');
243
265
  let tr = tbl.tr();
244
- tr.td().b().tx(this.translate('RES_REND_UPDATED'));
245
- tr.td().tx(this.displayDate(res.meta.version));
266
+ // RES_REND_UPDATED is "Last updated: {0}" — supply the formatted date as
267
+ // the {0} parameter so the placeholder is substituted.
268
+ tr.td().b().tx(this.translate('RES_REND_UPDATED', [this.displayDate(res.meta.lastUpdated)]));
246
269
  }
247
270
  }
248
271
 
@@ -318,7 +341,7 @@ class Renderer {
318
341
  }
319
342
 
320
343
  renderLinkComma(x, uri) {
321
- let {desc, url} = this.linkResolver ? this.linkResolver.resolveURL(this.opContext, uri) : null;
344
+ let {desc, url} = (this.linkResolver ? this.linkResolver.resolveURL(this.opContext, uri) : null) || {};
322
345
  if (url) {
323
346
  x.commaItem(desc, url);
324
347
  } else {
@@ -328,10 +351,17 @@ class Renderer {
328
351
 
329
352
 
330
353
  async renderCoding(x, coding) {
354
+ if (coding === null || coding === undefined || typeof coding !== 'object') {
355
+ this._invalid('Coding', coding, 'invalid Coding');
356
+ }
357
+ if (coding.code !== undefined && coding.code !== null &&
358
+ (typeof coding.code !== 'string' || coding.code.trim() === '')) {
359
+ this._invalid('Coding.code', coding.code, 'invalid code');
360
+ }
331
361
  let {
332
362
  desc,
333
363
  url
334
- } = this.linkResolver ? await this.linkResolver.resolveCode(this.opContext, coding.system, coding.version, coding.code) : null;
364
+ } = (this.linkResolver ? await this.linkResolver.resolveCode(this.opContext, coding.system, coding.version, coding.code) : null) || {};
335
365
  if (url) {
336
366
  x.ah(url).tx(desc);
337
367
  } else {
@@ -347,10 +377,254 @@ class Renderer {
347
377
  return this.opContext.i18n.formatPhrasePlural(msgId, this.opContext.langs, num,[]);
348
378
  }
349
379
 
350
- async renderValueSet(vs) {
351
- if (vs.json) {
352
- vs = vs.json;
380
+ /**
381
+ * Determine the BCP-47 locale to use for formatting, derived from the user's
382
+ * requested languages (parsed from the Accept-Language header). The region
383
+ * subtag (e.g. US vs GB) is what selects day/month ordering and month names.
384
+ * Falls back to 'en-US' when no usable language is available.
385
+ * @returns {string} a BCP-47 locale tag accepted by Intl
386
+ * @private
387
+ */
388
+ _formatLocale() {
389
+ const langs = this.opContext && this.opContext.langs;
390
+ if (langs) {
391
+ for (const lang of langs) {
392
+ if (lang.language && lang.language !== '*') {
393
+ let tag = lang.language;
394
+ if (lang.script) tag += '-' + lang.script;
395
+ if (lang.region) tag += '-' + lang.region;
396
+ try {
397
+ if (Intl.DateTimeFormat.supportedLocalesOf(tag).length > 0) {
398
+ return tag;
399
+ }
400
+ // Locale is structurally valid but not supported by the runtime;
401
+ // Intl will still resolve it to a sensible fallback, so use it.
402
+ return tag;
403
+ } catch (_) {
404
+ // Structurally invalid tag — skip and try the next language.
405
+ }
406
+ }
407
+ }
408
+ }
409
+ return 'en-US';
410
+ }
411
+
412
+ // ── Validation helpers ──────────────────────────────────────────────────────
413
+ // These exist so that illegal input is reported with an error that names the
414
+ // offending field and value, rather than being silently rendered or causing a
415
+ // generic "Cannot read properties of null" style crash further down.
416
+
417
+ /**
418
+ * Render a value for inclusion in an error message, distinguishing null,
419
+ * undefined, objects, and primitives, and truncating long values.
420
+ * @private
421
+ */
422
+ _showValue(value) {
423
+ if (value === undefined) return 'undefined';
424
+ if (value === null) return 'null';
425
+ if (typeof value === 'object') {
426
+ let s;
427
+ try { s = JSON.stringify(value); } catch (_) { s = Object.prototype.toString.call(value); }
428
+ return s.length > 80 ? s.slice(0, 77) + '...' : s;
429
+ }
430
+ const s = String(value);
431
+ return `'${s.length > 80 ? s.slice(0, 77) + '...' : s}'`;
432
+ }
433
+
434
+ /**
435
+ * Throw an InvalidError that identifies the offending value and its location.
436
+ * @param {string} path - dotted path to the offending element (e.g. "ConceptMap.group[0].target[0].relationship")
437
+ * @param {*} value - the offending value
438
+ * @param {string} what - short description of what is wrong (e.g. "invalid ConceptMap relationship")
439
+ * @private
440
+ */
441
+ _invalid(path, value, what) {
442
+ throw new InvalidError(`${what} at ${path}: ${this._showValue(value)}`);
443
+ }
444
+
445
+ /**
446
+ * Validate, unwrap, and type-check a resource before rendering. Accepts either
447
+ * a raw resource object or a wrapper exposing `.json`. Throws an InvalidError
448
+ * naming the problem when the input is missing, not an object, or of the wrong
449
+ * resourceType.
450
+ * @param {*} res - the resource (or wrapper) to render
451
+ * @param {string} expectedType - the FHIR resourceType this method renders
452
+ * @param {string} method - the calling method name, for the error message
453
+ * @returns {object} the unwrapped resource object
454
+ * @private
455
+ */
456
+ _resolveResource(res, expectedType, method) {
457
+ if (res === null || res === undefined) {
458
+ throw new InvalidError(`${method}: no resource supplied (got ${res === null ? 'null' : 'undefined'})`);
459
+ }
460
+ if (typeof res !== 'object' || Array.isArray(res)) {
461
+ throw new InvalidError(`${method}: expected a ${expectedType} resource object but got ${Array.isArray(res) ? 'an array' : typeof res}`);
462
+ }
463
+ const r = (res.json !== undefined && res.json !== null) ? res.json : res;
464
+ if (r === null || typeof r !== 'object' || Array.isArray(r)) {
465
+ throw new InvalidError(`${method}: expected a ${expectedType} resource object but got ${this._showValue(r)}`);
353
466
  }
467
+ if (r.resourceType !== undefined && r.resourceType !== expectedType) {
468
+ throw new InvalidError(`${method}: expected resourceType '${expectedType}' but found ${this._showValue(r.resourceType)}`);
469
+ }
470
+ return r;
471
+ }
472
+
473
+ /**
474
+ * True when `value` is a syntactically valid FHIR date/dateTime/instant.
475
+ * Used both to validate (via _requireValidDate) and to drive display.
476
+ * @private
477
+ */
478
+ _isValidFhirDate(value) {
479
+ if (typeof value !== 'string') return false;
480
+ if (/^\d{4}$/.test(value)) return true;
481
+
482
+ let m = /^(\d{4})-(\d{2})$/.exec(value);
483
+ if (m) {
484
+ const y = Number(m[1]), mo = Number(m[2]);
485
+ const d = new Date(Date.UTC(y, mo - 1, 1));
486
+ return !isNaN(d.getTime()) && d.getUTCFullYear() === y && d.getUTCMonth() === mo - 1;
487
+ }
488
+
489
+ m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
490
+ if (m) {
491
+ const y = Number(m[1]), mo = Number(m[2]), da = Number(m[3]);
492
+ const d = new Date(Date.UTC(y, mo - 1, da));
493
+ return !isNaN(d.getTime()) && d.getUTCFullYear() === y &&
494
+ d.getUTCMonth() === mo - 1 && d.getUTCDate() === da;
495
+ }
496
+
497
+ if (value.includes('T')) {
498
+ const dt = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(\.\d+)?(Z|[+-]\d{2}:\d{2})$/.exec(value);
499
+ if (!dt) return false;
500
+ if (isNaN(new Date(value).getTime())) return false;
501
+ const mo = Number(dt[2]), da = Number(dt[3]), hh = Number(dt[4]), mi = Number(dt[5]), ss = Number(dt[6]);
502
+ return mo >= 1 && mo <= 12 && da >= 1 && da <= 31 && hh <= 23 && mi <= 59 && ss <= 60;
503
+ }
504
+ return false;
505
+ }
506
+
507
+ /**
508
+ * Require a value (when present) to be a valid FHIR date; otherwise throw an
509
+ * error naming the field and value. Empty/absent values are allowed and
510
+ * returned unchanged.
511
+ * @private
512
+ */
513
+ _requireValidDate(value, path) {
514
+ if (value === null || value === undefined || value === '') return value;
515
+ if (!this._isValidFhirDate(value)) {
516
+ this._invalid(path, value, 'invalid date');
517
+ }
518
+ return value;
519
+ }
520
+
521
+ /**
522
+ * Require a value to be a non-empty string code; otherwise throw an error
523
+ * naming the field and value.
524
+ * @private
525
+ */
526
+ _requireValidCode(value, path) {
527
+ if (typeof value !== 'string' || value.trim() === '') {
528
+ this._invalid(path, value, 'invalid code');
529
+ }
530
+ return value;
531
+ }
532
+
533
+ /**
534
+ * Require a value (when present) to be a member of an allowed set; otherwise
535
+ * throw an error naming the field and value.
536
+ * @private
537
+ */
538
+ _requireAllowed(value, allowed, path, what) {
539
+ if (value === null || value === undefined) return value;
540
+ if (!allowed.has(value)) {
541
+ this._invalid(path, value, what);
542
+ }
543
+ return value;
544
+ }
545
+
546
+ /**
547
+ * Format a FHIR date / dateTime / instant value into a human-readable string
548
+ * using the user's locale. The output precision follows the input precision:
549
+ * a year stays a year, a year-month becomes "Month Year", a full date becomes
550
+ * a localised date, and a dateTime/instant becomes a localised date and time.
551
+ *
552
+ * Unparseable values are returned unchanged so the renderer never throws on
553
+ * unexpected input.
554
+ *
555
+ * @param {string} value - a FHIR date, dateTime, or instant (e.g. "2024",
556
+ * "2024-03", "2024-03-15", "2024-03-15T10:30:00Z")
557
+ * @returns {string} the localised representation, or '' for empty input
558
+ */
559
+ displayDate(value) {
560
+ if (value === null || value === undefined || value === '') {
561
+ return '';
562
+ }
563
+ if (typeof value !== 'string') {
564
+ value = String(value);
565
+ }
566
+
567
+ const locale = this._formatLocale();
568
+
569
+ // Year only — nothing to localise.
570
+ if (/^\d{4}$/.test(value)) {
571
+ return value;
572
+ }
573
+
574
+ // Year-month → "Month Year".
575
+ let m = /^(\d{4})-(\d{2})$/.exec(value);
576
+ if (m) {
577
+ const year = Number(m[1]), month = Number(m[2]);
578
+ const d = new Date(Date.UTC(year, month - 1, 1));
579
+ // Reject values that Date silently rolled over (e.g. month 13).
580
+ if (isNaN(d.getTime()) || d.getUTCFullYear() !== year || d.getUTCMonth() !== month - 1) {
581
+ return value;
582
+ }
583
+ return new Intl.DateTimeFormat(locale, {
584
+ year: 'numeric', month: 'long', timeZone: 'UTC'
585
+ }).format(d);
586
+ }
587
+
588
+ // Full date → localised date.
589
+ m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
590
+ if (m) {
591
+ const year = Number(m[1]), month = Number(m[2]), day = Number(m[3]);
592
+ const d = new Date(Date.UTC(year, month - 1, day));
593
+ // Reject values that Date silently rolled over (e.g. 2024-02-30).
594
+ if (isNaN(d.getTime()) || d.getUTCFullYear() !== year ||
595
+ d.getUTCMonth() !== month - 1 || d.getUTCDate() !== day) {
596
+ return value;
597
+ }
598
+ return new Intl.DateTimeFormat(locale, {
599
+ year: 'numeric', month: 'long', day: 'numeric', timeZone: 'UTC'
600
+ }).format(d);
601
+ }
602
+
603
+ // dateTime / instant → localised date and time.
604
+ if (value.includes('T')) {
605
+ const d = new Date(value);
606
+ if (isNaN(d.getTime())) return value;
607
+ const hasTimezone = /(Z|[+-]\d{2}:\d{2})$/.test(value);
608
+ const opts = {
609
+ year: 'numeric', month: 'long', day: 'numeric',
610
+ hour: '2-digit', minute: '2-digit', second: '2-digit'
611
+ };
612
+ if (hasTimezone) {
613
+ // We don't know the user's timezone, so render the absolute instant in
614
+ // UTC and label it, rather than silently using the server's zone.
615
+ opts.timeZone = 'UTC';
616
+ opts.timeZoneName = 'short';
617
+ }
618
+ return new Intl.DateTimeFormat(locale, opts).format(d);
619
+ }
620
+
621
+ // Unrecognised format — return unchanged.
622
+ return value;
623
+ }
624
+
625
+ async renderValueSet(vs) {
626
+ vs = this._resolveResource(vs, 'ValueSet', 'renderValueSet');
627
+ this._requireAllowed(vs.status, VALID_PUBLICATION_STATUS, 'ValueSet.status', 'invalid status');
354
628
 
355
629
  let div_ = div();
356
630
  div_.h2().tx("Properties");
@@ -369,9 +643,9 @@ class Renderer {
369
643
  }
370
644
 
371
645
  async renderCodeSystem(cs, sourcePackage) {
372
- if (cs.json) {
373
- cs = cs.json;
374
- }
646
+ cs = this._resolveResource(cs, 'CodeSystem', 'renderCodeSystem');
647
+ this._requireAllowed(cs.status, VALID_PUBLICATION_STATUS, 'CodeSystem.status', 'invalid status');
648
+ this._requireAllowed(cs.content, VALID_CODESYSTEM_CONTENT, 'CodeSystem.content', 'invalid CodeSystem content');
375
649
 
376
650
  let div_ = div();
377
651
 
@@ -399,7 +673,7 @@ class Renderer {
399
673
  p.tx(" ");
400
674
  p.startCommaList("and");
401
675
  for (let ext of supplements) {
402
- this.renderLinkComma(p, ext);
676
+ this.renderLinkComma(p, getValuePrimitive(ext));
403
677
  }
404
678
  p.stopCommaList();
405
679
  p.tx(".");
@@ -447,6 +721,7 @@ class Renderer {
447
721
  li.tx(":");
448
722
  const ul = li.ul();
449
723
  for (let c of inc.concept) {
724
+ this._requireValidCode(c.code, 'ValueSet.compose.include.concept.code');
450
725
  const li = ul.li();
451
726
  const link = this.linkResolver ? await this.linkResolver.resolveCode(this.opContext, inc.system, inc.version, c.code) : null;
452
727
  if (link) {
@@ -467,6 +742,7 @@ class Renderer {
467
742
  li.startCommaList("and");
468
743
  for (let f of inc.filter) {
469
744
  let op = this.readFilterOp(f);
745
+ this._requireAllowed(op, VALID_FILTER_OPS, 'ValueSet.compose.include.filter.op', 'invalid filter operator');
470
746
  if (op == 'exists') {
471
747
  if (f.value == "true") {
472
748
  li.commaItem(f.property+" "+ this.translate('VALUE_SET_EXISTS'));
@@ -485,13 +761,15 @@ class Renderer {
485
761
  }
486
762
  li.stopCommaList();
487
763
  }
488
- } else {
764
+ } else if (inc.valueSet && inc.valueSet.length > 0) {
489
765
  li.tx(this.translatePlural(inc.valueSet.length, 'VALUE_SET_RULES_INC'));
490
766
  li.startCommaList("and");
491
767
  for (let vs of inc.valueSet) {
492
768
  this.renderLinkComma(li, vs);
493
769
  }
494
770
  li.stopCommaList();
771
+ } else {
772
+ this._invalid('ValueSet.compose.include', inc, 'invalid ValueSet include (must specify a system or at least one valueSet)');
495
773
  }
496
774
  }
497
775
 
@@ -713,6 +991,7 @@ class Renderer {
713
991
  }
714
992
 
715
993
  async addConceptRow(tbl, concept, level, cs, columnInfo) {
994
+ this._requireValidCode(concept.code, 'CodeSystem.concept.code');
716
995
  const tr = tbl.tr();
717
996
 
718
997
  // Apply styling for deprecated concepts
@@ -1304,9 +1583,8 @@ class Renderer {
1304
1583
  }
1305
1584
 
1306
1585
  async renderCapabilityStatement(cs) {
1307
- if (cs.json) {
1308
- cs = cs.json;
1309
- }
1586
+ cs = this._resolveResource(cs, 'CapabilityStatement', 'renderCapabilityStatement');
1587
+ this._requireAllowed(cs.status, VALID_PUBLICATION_STATUS, 'CapabilityStatement.status', 'invalid status');
1310
1588
 
1311
1589
  let div_ = div();
1312
1590
 
@@ -1575,9 +1853,8 @@ class Renderer {
1575
1853
  }
1576
1854
 
1577
1855
  async renderTerminologyCapabilities(tc) {
1578
- if (tc.json) {
1579
- tc = tc.json;
1580
- }
1856
+ tc = this._resolveResource(tc, 'TerminologyCapabilities', 'renderTerminologyCapabilities');
1857
+ this._requireAllowed(tc.status, VALID_PUBLICATION_STATUS, 'TerminologyCapabilities.status', 'invalid status');
1581
1858
 
1582
1859
  let div_ = div();
1583
1860
 
@@ -1675,9 +1952,8 @@ class Renderer {
1675
1952
  * metadata table (reusing renderMetadataTable), then group-by-group rendering.
1676
1953
  */
1677
1954
  async renderConceptMap(cm) {
1678
- if (cm.json) {
1679
- cm = cm.json;
1680
- }
1955
+ cm = this._resolveResource(cm, 'ConceptMap', 'renderConceptMap');
1956
+ this._requireAllowed(cm.status, VALID_PUBLICATION_STATUS, 'ConceptMap.status', 'invalid status');
1681
1957
 
1682
1958
  let div_ = div();
1683
1959
 
@@ -2080,8 +2356,12 @@ class Renderer {
2080
2356
  */
2081
2357
  renderConceptMapRelationship(tr, tgt) {
2082
2358
  if (tgt.relationship) {
2359
+ this._requireAllowed(tgt.relationship, VALID_CONCEPTMAP_RELATIONSHIPS,
2360
+ 'ConceptMap.group.element.target.relationship', 'invalid ConceptMap relationship');
2083
2361
  tr.td().tx(this.presentRelationshipCode(tgt.relationship));
2084
2362
  } else if (tgt.equivalence) {
2363
+ this._requireAllowed(tgt.equivalence, VALID_CONCEPTMAP_EQUIVALENCES,
2364
+ 'ConceptMap.group.element.target.equivalence', 'invalid ConceptMap equivalence');
2085
2365
  tr.td().tx(this.presentEquivalenceCode(tgt.equivalence));
2086
2366
  } else {
2087
2367
  tr.td().tx("(" + "equivalent" + ")");
@@ -2252,6 +2532,7 @@ class Renderer {
2252
2532
  return f.op;
2253
2533
  }
2254
2534
  }
2535
+
2255
2536
  }
2256
2537
 
2257
2538
  module.exports = { Renderer };
@@ -939,7 +939,10 @@ class Registry {
939
939
  }
940
940
 
941
941
  register(handler) {
942
- this.handlers.set(handler.code, handler);
942
+ // Key on getCode() — the interface every handler implements. Some handlers
943
+ // (CelsiusHandler/FahrenheitHandler) override getCode() without setting a
944
+ // `.code` field, so keying on `.code` would register them under `undefined`.
945
+ this.handlers.set(handler.getCode(), handler);
943
946
  }
944
947
  }
945
948
 
package/tx/library.js CHANGED
@@ -1,7 +1,9 @@
1
1
  const fs = require('fs').promises;
2
2
  const path = require('path');
3
+ const crypto = require('crypto');
3
4
  const yaml = require('yaml'); // npm install yaml
4
5
  const { PackageManager, PackageContentLoader } = require('../library/package-manager');
6
+ const { FolderContentLoader } = require('../library/folder-content-loader');
5
7
  const { CodeSystem } = require("./library/codesystem");
6
8
  const {CountryCodeFactoryProvider} = require("./cs/cs-country");
7
9
  const {Iso4217FactoryProvider} = require("./cs/cs-currency");
@@ -295,6 +297,10 @@ class Library {
295
297
  await this.loadOcl(details, isDefault, mode);
296
298
  break;
297
299
 
300
+ case 'folder':
301
+ await this.loadFolder(details, isDefault, mode);
302
+ break;
303
+
298
304
  default:
299
305
  throw new Error(`Unknown source type: ${type}`);
300
306
  }
@@ -598,6 +604,23 @@ class Library {
598
604
  version = parts[1];
599
605
  }
600
606
  const packagePath = await packageManager.fetch(packageId, version);
607
+ await this.#loadPackageFromPath(packagePath, mode, csOnly);
608
+ }
609
+
610
+ async loadUrl(packageManager, url, isDefault, mode, csOnly) {
611
+ const packagePath = await packageManager.fetchUrl(url);
612
+ await this.#loadPackageFromPath(packagePath, mode, csOnly);
613
+ }
614
+
615
+ /**
616
+ * Shared loader for npm- and url-sourced packages. Given a fetched package
617
+ * path, loads its CodeSystems (and, unless csOnly, its ValueSets and
618
+ * ConceptMaps) into this library. Factored out of loadNpm/loadUrl so the two
619
+ * cannot drift apart (a past divergence here used Map-style .set() on the
620
+ * array-backed codeSystems list, breaking url sources).
621
+ * @private
622
+ */
623
+ async #loadPackageFromPath(packagePath, mode, csOnly) {
601
624
  if (mode === "fetch" || mode === "cs") {
602
625
  return;
603
626
  }
@@ -634,43 +657,64 @@ class Library {
634
657
  this.#logPackage(contentLoader.id(), contentLoader.version(), csc, vs ? vs.valueSetMap.size : 0);
635
658
  }
636
659
 
637
- async loadUrl(packageManager, url, isDefault, mode, csOnly) {
638
- const packagePath = await packageManager.fetchUrl(url);
660
+ /**
661
+ * Loads CodeSystem / ValueSet / ConceptMap resources from any *.json file in a folder.
662
+ * The folder is scanned (top level only); each JSON file is read and routed to the
663
+ * appropriate provider based on its resourceType. Files that fail to parse or whose
664
+ * resourceType isn't one of the three terminology types are silently skipped.
665
+ *
666
+ * Relative paths are resolved against the project root (same convention as loadUcum etc.).
667
+ *
668
+ * @param {string} details - The folder to scan
669
+ * @param {boolean} isDefault - Unused; folder sources don't register factories
670
+ * @param {string} mode - One of "fetch", "cs", "npm"
671
+ */
672
+ // eslint-disable-next-line no-unused-vars
673
+ async loadFolder(details, isDefault, mode) {
639
674
  if (mode === "fetch" || mode === "cs") {
640
675
  return;
641
676
  }
642
- const fullPackagePath = path.join(this.cacheFolder, packagePath);
643
- const contentLoader = new PackageContentLoader(fullPackagePath);
677
+
678
+ const folderPath = path.isAbsolute(details)
679
+ ? details
680
+ : path.resolve(path.join(__dirname, '..', details));
681
+
682
+ // Park the Package*Provider SQLite caches under the terminology cache rather
683
+ // than polluting the user's source folder, and wipe between runs so edits to
684
+ // the source folder are reliably picked up.
685
+ const hash = crypto.createHash('sha1').update(folderPath).digest('hex').substring(0, 16);
686
+ const cacheSubdir = path.join(this.cacheFolder, 'folder-source-' + hash);
687
+ await fs.rm(cacheSubdir, { recursive: true, force: true });
688
+ await fs.mkdir(cacheSubdir, { recursive: true });
689
+
690
+ const contentLoader = new FolderContentLoader(folderPath, cacheSubdir);
644
691
  await contentLoader.initialize();
645
692
 
646
- this.packageSources.push(contentLoader.id()+"#"+contentLoader.version());
693
+ this.packageSources.push(contentLoader.id() + "#" + contentLoader.version());
647
694
 
648
- let cp = new ListCodeSystemProvider();
649
- const resources = await contentLoader.getResourcesByType("CodeSystem");
695
+ const cp = new ListCodeSystemProvider();
696
+ const csEntries = await contentLoader.getResourcesByType("CodeSystem");
650
697
  let csc = 0;
651
- for (const resource of resources) {
652
- const cs = new CodeSystem(await contentLoader.loadFile(resource, contentLoader.fhirVersion()));
698
+ for (const entry of csEntries) {
699
+ const cs = new CodeSystem(await contentLoader.loadFile(entry, contentLoader.fhirVersion()));
653
700
  if (this.#isIgnored(cs.url, cs.version)) {
654
701
  this.log.info(`Ignoring CodeSystem ${cs.url}${cs.version ? '#' + cs.version : ''} (excluded by config)`);
655
702
  continue;
656
703
  }
657
704
  cs.sourcePackage = contentLoader.pid();
658
- cp.codeSystems.set(cs.url, cs);
659
- cp.codeSystems.set(cs.vurl, cs);
705
+ cp.codeSystems.push(cs);
660
706
  csc++;
661
707
  }
662
708
  this.codeSystemProviders.push(cp);
663
- let vs = null;
664
- if (!csOnly) {
665
- vs = new PackageValueSetProvider(contentLoader);
666
- await vs.initialize();
667
- this.valueSetProviders.push(vs);
668
- const cm = new PackageConceptMapProvider(contentLoader);
669
- await cm.initialize();
670
- this.conceptMapProviders.push(cm);
671
- }
672
709
 
673
- this.#logPackage(contentLoader.id(), contentLoader.version(), csc, vs ? vs.valueSetMap.size : 0);
710
+ const vs = new PackageValueSetProvider(contentLoader);
711
+ await vs.initialize();
712
+ this.valueSetProviders.push(vs);
713
+ const cm = new PackageConceptMapProvider(contentLoader);
714
+ await cm.initialize();
715
+ this.conceptMapProviders.push(cm);
716
+
717
+ this.#logPackage(contentLoader.id(), contentLoader.version(), csc, vs.valueSetMap.size);
674
718
  }
675
719
 
676
720
  /**
@@ -204,6 +204,10 @@ class ExpansionCache {
204
204
  this.cache = new Map();
205
205
  this.maxSize = maxSize;
206
206
  this.memoryThresholdBytes = memoryThresholdMB * 1024 * 1024;
207
+ // When true, every expansion is cached regardless of how long it took
208
+ // (bypasses MIN_CACHE_TIME_MS). Used by the test runner to force the cache
209
+ // path so cache correctness (e.g. language in the key) is exercised.
210
+ this.forceCaching = false;
207
211
  }
208
212
 
209
213
  /**
@@ -284,8 +288,9 @@ class ExpansionCache {
284
288
  * @returns {boolean} True if cached, false if duration too short
285
289
  */
286
290
  set(key, expansion, durationMs) {
287
- // Only cache if expansion took significant time
288
- if (durationMs < ExpansionCache.MIN_CACHE_TIME_MS) {
291
+ // Only cache if expansion took significant time, unless forceCaching is on
292
+ // (in which case everything is cached regardless of duration).
293
+ if (!this.forceCaching && durationMs < ExpansionCache.MIN_CACHE_TIME_MS) {
289
294
  return false;
290
295
  }
291
296
 
@@ -357,21 +362,6 @@ class ExpansionCache {
357
362
  return false;
358
363
  }
359
364
 
360
- /**
361
- * Force-store an expansion regardless of duration (for testing)
362
- * @param {string} key - Hash key
363
- * @param {Object} expansion - The expanded ValueSet
364
- */
365
- forceSet(key, expansion) {
366
- this.cache.set(key, {
367
- expansion: expansion,
368
- createdAt: Date.now(),
369
- lastUsed: Date.now(),
370
- durationMs: 0,
371
- hitCount: 0
372
- });
373
- }
374
-
375
365
  /**
376
366
  * Clear a specific entry
377
367
  * @param {string} key - Hash key
@@ -388,22 +378,22 @@ class ExpansionCache {
388
378
  }
389
379
 
390
380
  /**
391
- * Get cache statistics
381
+ * Get cache statistics.
382
+ * NB: named getStats(), not stats() — the `stats` field (the ServerStats
383
+ * passed to the constructor) would shadow a method called `stats`, making it
384
+ * unreachable.
392
385
  * @returns {Object} Stats object
393
386
  */
394
- stats() {
387
+ getStats() {
395
388
  let totalHits = 0;
396
- let totalDuration = 0;
397
389
  for (const entry of this.cache.values()) {
398
390
  totalHits += entry.hitCount;
399
- totalDuration += entry.durationMs;
400
391
  }
401
392
  return {
402
393
  size: this.cache.size,
403
394
  maxSize: this.maxSize,
404
395
  memoryThresholdMB: this.memoryThresholdBytes > 0 ? this.memoryThresholdBytes / (1024 * 1024) : 0,
405
- totalHits,
406
- totalDurationSaved: totalHits > 0 ? totalDuration * totalHits : 0
396
+ totalHits
407
397
  };
408
398
  }
409
399