fhirsmith 0.16.1 → 0.18.0

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 (142) hide show
  1. package/CHANGELOG.md +130 -0
  2. package/README.md +105 -30
  3. package/config-template.json +130 -4
  4. package/extension-tracker/extension-tracker.js +111 -3
  5. package/extension-tracker/readme.md +3 -0
  6. package/ips/ai-client.js +263 -0
  7. package/ips/composer.js +805 -0
  8. package/ips/editor-schema.js +289 -0
  9. package/ips/editor.js +290 -0
  10. package/ips/fhirpath-ips.js +397 -0
  11. package/ips/ips-codeable-concepts.md +1742 -0
  12. package/ips/ips-template.html +125 -0
  13. package/ips/ips-valuesets.js +159 -0
  14. package/ips/ips.js +1736 -0
  15. package/ips/membership.js +179 -0
  16. package/ips/pipeline.js +1012 -0
  17. package/ips/presenter.js +974 -0
  18. package/ips/profile-editor.js +578 -0
  19. package/ips/profiles.js +506 -0
  20. package/ips/readme.md +460 -0
  21. package/ips/render.js +1482 -0
  22. package/ips/search-params.js +83 -0
  23. package/ips/search.js +667 -0
  24. package/ips/share.js +178 -0
  25. package/ips/store.js +650 -0
  26. package/ips/terminology.js +395 -0
  27. package/ips/tx-client.js +202 -0
  28. package/ips/valuesets/CodeSystem-absent-unknown-uv-ips.json +206 -0
  29. package/ips/valuesets/ValueSet-absent-or-unknown-immunizations-uv-ips.json +59 -0
  30. package/ips/valuesets/ValueSet-ips-allergy-code.json +59 -0
  31. package/ips/valuesets/ValueSet-ips-allergy-manifestation.json +356 -0
  32. package/ips/valuesets/ValueSet-ips-allergy-substance.json +37 -0
  33. package/ips/valuesets/ValueSet-ips-body-site.json +26 -0
  34. package/ips/valuesets/ValueSet-ips-clinical-finding-or-none.json +35 -0
  35. package/ips/valuesets/ValueSet-ips-clinical-finding.json +26 -0
  36. package/ips/valuesets/ValueSet-ips-condition-code.json +72 -0
  37. package/ips/valuesets/ValueSet-ips-condition-severity.json +30 -0
  38. package/ips/valuesets/ValueSet-ips-condition-stage.json +26 -0
  39. package/ips/valuesets/ValueSet-ips-exposure-route.json +26 -0
  40. package/ips/valuesets/ValueSet-ips-imaging-procedure.json +25 -0
  41. package/ips/valuesets/ValueSet-ips-immunization-status-reason.json +43 -0
  42. package/ips/valuesets/ValueSet-ips-international-codes.json +25 -0
  43. package/ips/valuesets/ValueSet-ips-medication-ingredient.json +26 -0
  44. package/ips/valuesets/ValueSet-ips-medication-status-reason.json +37 -0
  45. package/ips/valuesets/ValueSet-ips-medication.json +50 -0
  46. package/ips/valuesets/ValueSet-ips-observation-code.json +18 -0
  47. package/ips/valuesets/ValueSet-ips-observation-interpretation.json +18 -0
  48. package/ips/valuesets/ValueSet-ips-procedure-followup.json +51 -0
  49. package/ips/valuesets/ValueSet-ips-procedure-outcome.json +30 -0
  50. package/ips/valuesets/ValueSet-ips-procedure-reason.json +37 -0
  51. package/ips/valuesets/ValueSet-ips-procedure.json +116 -0
  52. package/ips/valuesets/ValueSet-ips-route.json +46 -0
  53. package/ips/valuesets/ValueSet-ips-target-disease.json +171 -0
  54. package/ips/valuesets/ValueSet-ips-vaccine.json +31 -0
  55. package/ips/valuesets/elements.json +44 -0
  56. package/library/daily-report.js +422 -0
  57. package/library/fhir-search-values.js +138 -0
  58. package/library/html-server.js +32 -23
  59. package/library/html.js +9 -0
  60. package/library/mailer.js +77 -0
  61. package/library/narrative-check.js +222 -0
  62. package/library/npm-audit.js +25 -0
  63. package/library/package-manager.js +4 -2
  64. package/library/sqlite-session-store.js +29 -1
  65. package/package.json +8 -2
  66. package/packages/package-crawler.js +189 -82
  67. package/packages/packages.js +36 -63
  68. package/packages/utilities/dedupe-versions.js +22 -8
  69. package/publisher/publisher.js +29 -0
  70. package/registry/auth-store.js +366 -0
  71. package/registry/auth.js +872 -0
  72. package/registry/crawler.js +48 -1
  73. package/registry/documentation/api.md +162 -0
  74. package/registry/documentation/configuration.md +93 -0
  75. package/registry/documentation/documents.md +74 -0
  76. package/registry/documentation/internals.md +151 -0
  77. package/registry/documentation/readme.md +100 -0
  78. package/registry/documentation/security.md +199 -0
  79. package/registry/documents.js +309 -0
  80. package/registry/openapi.yaml +30 -0
  81. package/registry/registry-template.html +1 -1
  82. package/registry/registry.js +247 -54
  83. package/registry/uptime.js +154 -0
  84. package/release.sh +80 -0
  85. package/server.js +105 -44
  86. package/shl/shl.js +64 -9
  87. package/stats-db.js +93 -0
  88. package/stats.js +158 -1
  89. package/testing/readme.md +1 -1
  90. package/testing/search.js +2 -108
  91. package/testing/store.js +5 -0
  92. package/testing/testing.js +20 -0
  93. package/token/token-template.html +5 -4
  94. package/token/token.js +17 -10
  95. package/translations/Messages.properties +9 -0
  96. package/tx/README.md +52 -0
  97. package/tx/cm/cm-database.js +62 -0
  98. package/tx/cm/cm-package.js +20 -3
  99. package/tx/cs/cs-api.js +90 -14
  100. package/tx/cs/cs-cs.js +142 -19
  101. package/tx/cs/cs-icd11.js +366 -112
  102. package/tx/cs/cs-icf.js +418 -0
  103. package/tx/cs/cs-loinc.js +79 -39
  104. package/tx/cs/cs-omop.js +10 -3
  105. package/tx/cs/cs-snomed.js +918 -55
  106. package/tx/cs/cs-ucum.js +6 -0
  107. package/tx/cs/icd11-expressions.js +191 -78
  108. package/tx/importers/export-icd11-codesystem.js +58 -12
  109. package/tx/importers/icd11-schema.md +9 -5
  110. package/tx/library.js +107 -6
  111. package/tx/ocl/README.md +62 -6
  112. package/tx/ocl/cm-ocl.cjs +166 -44
  113. package/tx/ocl/cs-ocl.cjs +163 -13
  114. package/tx/ocl/resolve/public-access.js +143 -0
  115. package/tx/ocl/resolve/reference-resolver.js +458 -0
  116. package/tx/ocl/vs-ocl.cjs +167 -4
  117. package/tx/openapi-operations.js +4 -0
  118. package/tx/params.js +8 -1
  119. package/tx/problems.js +7 -5
  120. package/tx/provider.js +21 -5
  121. package/tx/sct/documentation.md +251 -0
  122. package/tx/sct/ecl.js +758 -46
  123. package/tx/sct/expressions.js +113 -52
  124. package/tx/syndication/syndication-feed.js +218 -0
  125. package/tx/syndication/syndication-store.js +415 -0
  126. package/tx/tx-html.js +37 -8
  127. package/tx/tx.fhir.org.yml +2 -3
  128. package/tx/tx.js +148 -5
  129. package/tx/vs/vs-database.js +26 -0
  130. package/tx/vs/vs-package.js +42 -10
  131. package/tx/vs/vs-vsac.js +25 -6
  132. package/tx/workers/expand.js +34 -7
  133. package/tx/workers/lookup.js +61 -6
  134. package/tx/workers/metadata.js +6 -1
  135. package/tx/workers/subsumes.js +1 -1
  136. package/tx/workers/translate.js +166 -15
  137. package/tx/workers/validate.js +128 -6
  138. package/utilities/make-sct-test-extension.js +264 -0
  139. package/utilities/submit-test-report.js +1 -1
  140. package/xig/xig.js +120 -12
  141. package/gr.sh +0 -3
  142. package/registry/readme.md +0 -124
package/tx/library.js CHANGED
@@ -5,6 +5,7 @@ const yaml = require('yaml'); // npm install yaml
5
5
  const { PackageManager, PackageContentLoader } = require('../library/package-manager');
6
6
  const { FolderContentLoader, FileContentLoader } = require('../library/folder-content-loader');
7
7
  const { CodeSystem } = require("./library/codesystem");
8
+ const { assertNoActiveContent } = require('../library/narrative-check');
8
9
  const {CountryCodeFactoryProvider} = require("./cs/cs-country");
9
10
  const {Iso4217FactoryProvider} = require("./cs/cs-currency");
10
11
  const {AreaCodeFactoryProvider} = require("./cs/cs-areacode");
@@ -92,6 +93,12 @@ class Library {
92
93
  totalDownloaded = 0;
93
94
  vsacCfg = undefined;
94
95
 
96
+ /**
97
+ * {SyndicationStore|null} where the content this library loads is recorded for the
98
+ * syndication feed, when the server has one (see tx/syndication)
99
+ */
100
+ syndication = null;
101
+
95
102
  registerProvider(source, factory, isDefault = false) {
96
103
  this.#logSystem(factory.system(), factory.version(), source);
97
104
  if (isDefault || !this.codeSystemFactories.has(factory.system())) {
@@ -606,6 +613,7 @@ class Library {
606
613
  const loinc = new LoincServicesFactory(this.i18n, loincFN);
607
614
  await loinc.load();
608
615
  this.registerProvider(loincFN, loinc, isDefault);
616
+ this.#syndicateBinary('loinc', loincFN, loinc);
609
617
  }
610
618
 
611
619
  async loadRxnorm(details, isDefault, mode) {
@@ -616,6 +624,7 @@ class Library {
616
624
  const rxn = new RxNormServicesFactory(this.i18n, rxNormFN);
617
625
  await rxn.load();
618
626
  this.registerProvider(rxNormFN, rxn, isDefault);
627
+ this.#syndicateBinary('rxnorm', rxNormFN, rxn);
619
628
  }
620
629
 
621
630
  async loadNdc(details, isDefault, mode) {
@@ -626,6 +635,7 @@ class Library {
626
635
  const ndc = new NdcServicesFactory(this.i18n, ndcFN);
627
636
  await ndc.load();
628
637
  this.registerProvider(ndcFN, ndc, isDefault);
638
+ this.#syndicateBinary('ndc', ndcFN, ndc);
629
639
  }
630
640
 
631
641
  async loadUnii(details, isDefault, mode) {
@@ -636,6 +646,7 @@ class Library {
636
646
  const unii = new UniiServicesFactory(this.i18n, uniFN);
637
647
  await unii.load();
638
648
  this.registerProvider(uniFN, unii, isDefault);
649
+ this.#syndicateBinary('unii', uniFN, unii);
639
650
  }
640
651
 
641
652
  async loadSnomed(details, isDefault, mode) {
@@ -646,6 +657,7 @@ class Library {
646
657
  const sct = new SnomedServicesFactory(this.i18n, sctFN);
647
658
  await sct.load();
648
659
  this.registerProvider(sctFN, sct, isDefault);
660
+ this.#syndicateBinary('snomed', sctFN, sct);
649
661
  }
650
662
 
651
663
  async loadCpt(details, isDefault, mode) {
@@ -656,6 +668,8 @@ class Library {
656
668
  const cpt = new CPTServicesFactory(this.i18n, cptFN);
657
669
  await cpt.load();
658
670
  this.registerProvider(cptFN, cpt, isDefault);
671
+ // the store syndicates the public fragment only, never the full (licensed) database
672
+ this.#syndicateBinary('cpt', cptFN, cpt);
659
673
  }
660
674
 
661
675
  async loadOmop(details, isDefault, mode) {
@@ -666,6 +680,7 @@ class Library {
666
680
  const omop = new OMOPServicesFactory(this.i18n, omopFN);
667
681
  await omop.load();
668
682
  this.registerProvider(omopFN, omop, isDefault);
683
+ this.#syndicateBinary('omop', omopFN, omop);
669
684
  }
670
685
 
671
686
  /**
@@ -678,13 +693,69 @@ class Library {
678
693
  if (mode === "fetch" || mode === "npm") {
679
694
  return;
680
695
  }
696
+ // one file, so one syndication entry - identified by the MMS, if the file has it
697
+ let main = null;
681
698
  for (const sys of await ICD11ServicesFactory.listSystems(icdFN)) {
682
699
  const factory = new ICD11ServicesFactory(this.i18n, icdFN, sys.code);
683
700
  await factory.load();
684
701
  this.registerProvider(icdFN, factory, isDefault);
702
+ if (!main || sys.code === 'mms') {
703
+ main = factory;
704
+ }
705
+ }
706
+ if (main) {
707
+ this.#syndicateBinary('icd11', icdFN, main);
685
708
  }
686
709
  }
687
710
 
711
+ /**
712
+ * Records a binary source file (SNOMED cache, LOINC database etc) in the syndication store,
713
+ * if there is one. The store works in the background, and decides itself whether the file
714
+ * is one it syndicates; nothing it does can stop the library loading.
715
+ * @private
716
+ */
717
+ #syndicateBinary(sourceType, filePath, factory) {
718
+ if (!this.syndication) {
719
+ return;
720
+ }
721
+ try {
722
+ let name = null;
723
+ try {
724
+ name = factory.name();
725
+ } catch (e) {
726
+ // not every factory has a name
727
+ }
728
+ void this.syndication.inductBinary({
729
+ sourceType, filePath, baseUrl: this.baseUrl, system: factory.system(), version: factory.version(), name
730
+ });
731
+ } catch (error) {
732
+ this.log.warn(`Syndication: unable to record ${filePath}: ${error.message}`);
733
+ }
734
+ }
735
+
736
+ /**
737
+ * Records a package in the syndication store, if there is one.
738
+ * @param {PackageContentLoader} contentLoader - the loaded package
739
+ * @param {Object} source - {requestedVersion} for an npm: source, {url} for a url: source,
740
+ * {fhirVersion} (major.minor) for an endpoint's core package
741
+ */
742
+ syndicatePackage(contentLoader, source = {}) {
743
+ if (!this.syndication || !contentLoader || !contentLoader.package) {
744
+ return;
745
+ }
746
+ const pkg = contentLoader.package;
747
+ void this.syndication.inductPackage({
748
+ id: contentLoader.id(),
749
+ version: contentLoader.version(),
750
+ canonical: pkg.canonical,
751
+ title: pkg.title,
752
+ description: pkg.description,
753
+ url: source.url,
754
+ fhirVersion: source.fhirVersion,
755
+ requestedVersion: source.requestedVersion
756
+ });
757
+ }
758
+
688
759
  /**
689
760
  * Returns true if the given url/version should be excluded from npm/url package loading.
690
761
  * Matches against the ignored list using either plain url or url#version.
@@ -706,12 +777,12 @@ class Library {
706
777
  version = parts[1];
707
778
  }
708
779
  const packagePath = await packageManager.fetch(packageId, version);
709
- await this.#loadPackageFromPath(packagePath, mode, csOnly);
780
+ await this.#loadPackageFromPath(packagePath, mode, csOnly, { requestedVersion: version });
710
781
  }
711
782
 
712
783
  async loadUrl(packageManager, url, isDefault, mode, csOnly) {
713
784
  const packagePath = await packageManager.fetchUrl(url);
714
- await this.#loadPackageFromPath(packagePath, mode, csOnly);
785
+ await this.#loadPackageFromPath(packagePath, mode, csOnly, { url });
715
786
  }
716
787
 
717
788
  /**
@@ -722,13 +793,14 @@ class Library {
722
793
  * array-backed codeSystems list, breaking url sources).
723
794
  * @private
724
795
  */
725
- async #loadPackageFromPath(packagePath, mode, csOnly) {
796
+ async #loadPackageFromPath(packagePath, mode, csOnly, source) {
726
797
  if (mode === "fetch" || mode === "cs") {
727
798
  return;
728
799
  }
729
800
  const fullPackagePath = path.join(this.cacheFolder, packagePath);
730
801
  const contentLoader = new PackageContentLoader(fullPackagePath);
731
802
  await contentLoader.initialize();
803
+ this.syndicatePackage(contentLoader, source);
732
804
 
733
805
  this.packageSources.push(contentLoader.id()+"#"+contentLoader.version());
734
806
 
@@ -738,7 +810,10 @@ class Library {
738
810
  for (const resource of resources) {
739
811
  let cs;
740
812
  try {
741
- cs = new CodeSystem(await contentLoader.loadFile(resource, contentLoader.fhirVersion()));
813
+ const json = await contentLoader.loadFile(resource, contentLoader.fhirVersion());
814
+ // narrative is shown as-is in a page, so it must not carry script (see narrative-check.js)
815
+ assertNoActiveContent(json);
816
+ cs = new CodeSystem(json);
742
817
  } catch (e) {
743
818
  // one unacceptable resource (e.g. contained resources the server doesn't support)
744
819
  // must not stop the rest of the package loading
@@ -848,6 +923,27 @@ class Library {
848
923
  */
849
924
  async #loadLocalSource(contentLoader) {
850
925
  await contentLoader.initialize();
926
+ if (this.syndication) {
927
+ // each resource is an entry in its own right (those without a version are skipped)
928
+ for (const entry of contentLoader.index.files) {
929
+ if (this.#isIgnored(entry.url, entry.version)) {
930
+ continue;
931
+ }
932
+ let title = null;
933
+ let description = null;
934
+ try {
935
+ const json = JSON.parse(await fs.readFile(path.join(contentLoader.sourceFolder, entry.filename), 'utf8'));
936
+ title = json.title || json.name || null;
937
+ description = typeof json.description === 'string' ? json.description : null;
938
+ } catch (e) {
939
+ // initialize() already read it; if it can't now, the store will say so
940
+ }
941
+ void this.syndication.inductResource({
942
+ filePath: path.join(contentLoader.sourceFolder, entry.filename),
943
+ resourceType: entry.resourceType, url: entry.url, version: entry.version, title, description
944
+ });
945
+ }
946
+ }
851
947
 
852
948
  this.packageSources.push(contentLoader.id() + "#" + contentLoader.version());
853
949
 
@@ -857,7 +953,10 @@ class Library {
857
953
  for (const entry of csEntries) {
858
954
  let cs;
859
955
  try {
860
- cs = new CodeSystem(await contentLoader.loadFile(entry, contentLoader.fhirVersion()));
956
+ const json = await contentLoader.loadFile(entry, contentLoader.fhirVersion());
957
+ // narrative is shown as-is in a page, so it must not carry script (see narrative-check.js)
958
+ assertNoActiveContent(json);
959
+ cs = new CodeSystem(json);
861
960
  } catch (e) {
862
961
  // one unacceptable resource must not stop the rest of the package loading
863
962
  this.log.warn(`Package ${contentLoader.pid()}: skipping CodeSystem ${entry.id || entry.filename || ''}: ${e.message}`);
@@ -1043,7 +1142,9 @@ class Library {
1043
1142
 
1044
1143
  // Load FHIR packages - these will be added to valueSetProviders first
1045
1144
  for (const packageId of fhirPackages) {
1046
- await provider.loadNpm(this.packageManager, this.cacheFolder, packageId, false, "npm", false);
1145
+ const contentLoader = await provider.loadNpm(this.packageManager, this.cacheFolder, packageId, false, "npm", false);
1146
+ // the core packages are the only version specific content in the syndication store
1147
+ this.syndicatePackage(contentLoader, { fhirVersion: VersionUtilities.getMajMin(String(fhirVersion)) });
1047
1148
  }
1048
1149
 
1049
1150
 
package/tx/ocl/README.md CHANGED
@@ -39,14 +39,70 @@ In FHIRsmith terms, these providers are loaded by `tx/library.js` when a source
39
39
  ## Runtime flow
40
40
  ### Metadata discovery
41
41
  CodeSystems (`cs-ocl.js`):
42
- - discover orgs via `/orgs/`
43
- - for each org, discover sources via `/orgs/{org}/sources/`
44
- - fallback to `/sources/` if org listing is unavailable
42
+ - prefer one paginated crawl of the global `/sources/` listing (filtered to
43
+ organization-owned entries)
44
+ - fallback: discover orgs via `/orgs/`, then `/orgs/{org}/sources/` per org
45
45
 
46
46
  ValueSets (`vs-ocl.js`):
47
- - discover orgs via `/orgs/`
48
- - discover collections via `/orgs/{org}/collections/`
49
- - fallback to `/collections/`
47
+ - prefer one paginated crawl of the global `/collections/` listing (filtered to
48
+ organization-owned entries)
49
+ - fallback: discover orgs via `/orgs/`, then `/orgs/{org}/collections/` per org
50
+
51
+ ### Visibility policy (org-only + public-only)
52
+ An artifact is expected to live in an **organization** to be visible through the
53
+ terminology service. User-owned repos (`/users/{user}/...`) are experimental by
54
+ convention and are excluded from both discovery and `$resolveReference` results
55
+ (a canonical resolving to a user-owned repo is treated as unresolved).
56
+
57
+ In addition, a repo is only served when it is **publicly viewable**
58
+ (`public_access` is `View`/`Edit`; `None`/unknown is treated as not servable,
59
+ fail-closed). Because the configured token can see every repo its OCL account has
60
+ access to — and both `$resolveReference` and discovery answer anonymous terminology
61
+ requests — without this gate a *private* repo in the token's org could be surfaced
62
+ publicly. This gate is applied in **both** paths:
63
+
64
+ - **`$resolveReference`**: a resolved repo that is not public is treated as unresolved.
65
+ - **Discovery**: the global (`/sources/`, `/collections/`) and per-org listing crawls
66
+ drop non-public (and non-org-owned) repos before they are registered.
67
+
68
+ `public_access` is not returned by the listing endpoints or by `$resolveReference`
69
+ today, so a shared `PublicAccessChecker` fetches each repo once to read it, caching
70
+ the verdict by `repoUrl` (bounded LRU) and running batch checks with a concurrency
71
+ limit. A check that *errors* is not cached, so a transient blip is retried rather
72
+ than pinning a repo as private. The resolver also rejects any `repoUrl` that is not
73
+ a safe same-host relative path, so an unexpected response can never redirect an
74
+ authenticated request off-host.
75
+
76
+ > **Operational guidance (defense-in-depth):** configure the `token=` account with
77
+ > the **least privilege** needed — ideally a service account that can see only
78
+ > public repos. The `public_access` gate enforces public-only serving in code
79
+ > regardless, but a least-privilege token means a misconfiguration can never
80
+ > expose private content.
81
+
82
+ ### Canonical resolution via `$resolveReference`
83
+ With a `token=` configured on the `ocl:` source line, the providers resolve
84
+ "which repo holds this canonical URL?" through OCL's
85
+ [`$resolveReference`](https://docs.openconceptlab.org/en/latest/oclapi/apireference/resolveReference.html)
86
+ (global namespace) instead of iterating listings and matching `canonical_url`
87
+ client-side. Used for ConceptMap source-system search, ValueSet lookup by
88
+ canonical, and batched resolution of a collection's compose source canonicals.
89
+
90
+ Without a token nothing changes: `$resolveReference` is authenticated on every
91
+ instance probed (while the listing endpoints are public), so the resolver is
92
+ constructed disabled and every caller keeps its previous search path. A `404`
93
+ (endpoint not implemented) disables it for the process; a transient `401`/`403`
94
+ backs it off for a cooldown and then retries. The resolution cache is a bounded
95
+ LRU and over-long references are never sent to OCL.
96
+
97
+ ### CodeSystem default versions (release vs HEAD)
98
+ OCL's own resolution treats a source's **latest release** as its default version
99
+ (HEAD only when nothing is released), but discovery listings only ever report
100
+ HEAD. With a token, discovery batch-resolves every canonical through
101
+ `$resolveReference` and registers **both** versions: the release becomes what a
102
+ versionless request gets (matching OCL), and HEAD stays reachable via an
103
+ explicit `version=HEAD`. Only canonicals that are new or changed are re-resolved
104
+ on refresh, so a steady-state cycle costs no extra requests. Without a token,
105
+ discovery stays HEAD-only as before.
50
106
 
51
107
  ConceptMaps (`cm-ocl.js`):
52
108
  - fetch by id via `/mappings/{id}/`
package/tx/ocl/cm-ocl.cjs CHANGED
@@ -4,9 +4,17 @@ const { PAGE_SIZE } = require('./shared/constants');
4
4
  const { createOclHttpClient } = require('./http/client');
5
5
  const { fetchAllPages, extractItemsAndNext } = require('./http/pagination');
6
6
  const { stripIdPrefix } = require('../library/resource-ids');
7
+ const { OclReferenceResolver, isOclRepoPath } = require('./resolve/reference-resolver');
8
+ const Logger = require('../../library/logger');
9
+ const oclCmLog = Logger.getInstance().child({ module: 'ocl-cm' });
7
10
 
8
11
  const DEFAULT_MAX_SEARCH_PAGES = 10;
9
12
 
13
+ // OCL map types that relate concepts without mapping one to the other, so they must
14
+ // not surface as $translate matches: CIEL uses Q-AND-A (question -> answer) and
15
+ // CONCEPT-SET (set -> member). Unknown types still map to related-to.
16
+ const NON_TRANSLATION_MAP_TYPES = new Set(['Q-AND-A', 'CONCEPT-SET']);
17
+
10
18
  class OCLConceptMapProvider extends AbstractConceptMapProvider {
11
19
  constructor(config = {}) {
12
20
  super();
@@ -23,6 +31,15 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider {
23
31
  this._sourceCandidatesCache = new Map();
24
32
  this._sourceUrlsByCanonical = new Map();
25
33
  this._canonicalBySourceUrl = new Map();
34
+
35
+ // Asks OCL which repo holds a canonical instead of guessing via text search.
36
+ // Disabled unless a token is configured, in which case #candidateSourceUrls
37
+ // keeps using its existing search.
38
+ this.referenceResolver = new OclReferenceResolver({
39
+ httpClient: this.httpClient,
40
+ token: options.token || null,
41
+ logger: oclCmLog
42
+ });
26
43
  }
27
44
 
28
45
  prefixIds() {
@@ -114,8 +131,12 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider {
114
131
  async searchConceptMaps(searchParams, _elements) {
115
132
  this._validateSearchParams(searchParams);
116
133
 
134
+ // Keep the canonical exactly as supplied. Lower-casing it was harmless while
135
+ // every consumer compared through #norm() (which lower-cases anyway), but
136
+ // $resolveReference matches the canonical exactly, and OCL has no
137
+ // .../fhir/codesystem/x -- only .../fhir/CodeSystem/X.
117
138
  const params = Object.fromEntries(
118
- searchParams.map(({ name, value }) => [name, String(value).toLowerCase()])
139
+ searchParams.map(({ name, value }) => [name, String(value)])
119
140
  );
120
141
  const sourceSystem = params['source-system'] || params.source || null;
121
142
  const targetSystem = params['target-system'] || params.target || null;
@@ -138,7 +159,7 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider {
138
159
  async #collectMappingsForSearch(sourceSystem, targetSystem) {
139
160
  const systemUrl = sourceSystem || targetSystem;
140
161
  const candidates = await this.#candidateSourceUrls(systemUrl);
141
- const sourcePaths = candidates.filter(s => String(s || '').startsWith('/orgs/'));
162
+ const sourcePaths = candidates.filter(isOclRepoPath);
142
163
 
143
164
  if (sourcePaths.length === 0) {
144
165
  return [];
@@ -147,30 +168,20 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider {
147
168
  const allMappings = [];
148
169
  for (const sourcePath of sourcePaths) {
149
170
  const normalizedPath = this.#normalizeSourcePath(sourcePath);
150
- let concepts;
151
171
  try {
152
- concepts = await this.#fetchAllPages(
153
- `${normalizedPath}concepts/`, { limit: PAGE_SIZE }, this.maxSearchPages
172
+ // Ask the source for its mappings directly. Walking concepts and asking
173
+ // each one costs a request per concept and, worse, only ever sees the
174
+ // first maxSearchPages of them -- for LOINC that is 1000 of 184683
175
+ // concepts (0.5%), so any mapping past that point was silently invisible.
176
+ // Verified live: both paths return the identical mapping set.
177
+ const mappings = await this.#fetchAllPages(
178
+ `${normalizedPath}mappings/`, { limit: PAGE_SIZE }, this.maxSearchPages
154
179
  );
180
+ allMappings.push(...mappings);
155
181
  } catch (_err) {
182
+ // source exposes no mappings endpoint or is inaccessible — skip
156
183
  continue;
157
184
  }
158
-
159
- for (const concept of concepts) {
160
- const code = concept.id || concept.mnemonic;
161
- if (!code) {
162
- continue;
163
- }
164
- try {
165
- const mappings = await this.#fetchAllPages(
166
- `${normalizedPath}concepts/${encodeURIComponent(code)}/mappings/`,
167
- { limit: PAGE_SIZE }, 2
168
- );
169
- allMappings.push(...mappings);
170
- } catch (_err) {
171
- // concept has no mappings or endpoint inaccessible — skip
172
- }
173
- }
174
185
  }
175
186
 
176
187
  const sourceUrlsToResolve = new Set();
@@ -194,7 +205,7 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider {
194
205
  const sourceCode = mapping.from_concept_code || mapping.fromConceptCode;
195
206
  const targetCode = mapping.to_concept_code || mapping.toConceptCode;
196
207
 
197
- if (!fromSource || !toSource || !sourceCode || !targetCode) {
208
+ if (!fromSource || !toSource || !sourceCode || !targetCode || !this.#isTranslation(mapping)) {
198
209
  continue;
199
210
  }
200
211
 
@@ -252,8 +263,8 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider {
252
263
  name: id,
253
264
  title: `${sourceId} to ${targetId}`,
254
265
  status: 'active',
255
- sourceScopeUri: group.sourceCanonical,
256
- targetScopeUri: group.targetCanonical,
266
+ // No sourceScope/targetScope: a scope is a ValueSet, and $translate resolves it and
267
+ // checks the code against it. The systems are carried by the group instead.
257
268
  group: [{
258
269
  source: group.sourceCanonical,
259
270
  target: group.targetCanonical,
@@ -290,7 +301,7 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider {
290
301
  const targetCandidates = await this.#candidateSourceUrls(targetSystem);
291
302
 
292
303
  const mappings = [];
293
- const sourcePaths = sourceCandidates.filter(s => String(s || '').startsWith('/orgs/'));
304
+ const sourcePaths = sourceCandidates.filter(isOclRepoPath);
294
305
 
295
306
  if (sourceCode && sourcePaths.length > 0) {
296
307
  for (const sourcePath of sourcePaths) {
@@ -410,7 +421,7 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider {
410
421
  const sourceCode = mapping.from_concept_code || mapping.fromConceptCode;
411
422
  const targetCode = mapping.to_concept_code || mapping.toConceptCode;
412
423
 
413
- if (!source || !target || !sourceCode || !targetCode) {
424
+ if (!source || !target || !sourceCode || !targetCode || !this.#isTranslation(mapping)) {
414
425
  return null;
415
426
  }
416
427
 
@@ -430,8 +441,10 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider {
430
441
  name: `mapping-${id}`,
431
442
  title: mapping.name || `Mapping ${id}`,
432
443
  status: 'active',
433
- sourceScopeUri: mapping.from_collection_url || mapping.fromCollectionUrl || source,
434
- targetScopeUri: mapping.to_collection_url || mapping.toCollectionUrl || target,
444
+ // No sourceScope/targetScope: a scope is a ValueSet canonical, which $translate resolves
445
+ // and checks the code against. OCL only offers repo paths here (from_source_url or
446
+ // from_collection_url), which resolve to no ValueSet and made every translation fail
447
+ // with not-found. The systems are carried by the group instead.
435
448
  group: [
436
449
  {
437
450
  source: sourceCanonical,
@@ -461,21 +474,32 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider {
461
474
  return new ConceptMap(json, 'R5');
462
475
  }
463
476
 
477
+ // OCL map_type expresses "from concept <relation> to concept", which lines up
478
+ // with the R5 ConceptMapRelationship codes (source <relation> target).
464
479
  #toRelationship(mapType) {
465
- switch ((mapType || '').toUpperCase()) {
480
+ switch (this.#normalizeMapType(mapType)) {
466
481
  case 'SAME-AS':
467
482
  return 'equivalent';
468
483
  case 'NARROWER-THAN':
469
- return 'narrower-than';
484
+ return 'source-is-narrower-than-target';
470
485
  case 'BROADER-THAN':
471
- return 'broader-than';
486
+ return 'source-is-broader-than-target';
472
487
  case 'NOT-EQUIVALENT':
488
+ case 'NOT-SAME-AS':
473
489
  return 'not-related-to';
474
490
  default:
475
491
  return 'related-to';
476
492
  }
477
493
  }
478
494
 
495
+ #isTranslation(mapping) {
496
+ return !NON_TRANSLATION_MAP_TYPES.has(this.#normalizeMapType(mapping.map_type || mapping.mapType));
497
+ }
498
+
499
+ #normalizeMapType(mapType) {
500
+ return String(mapType || '').trim().toUpperCase().replace(/[\s_]+/g, '-');
501
+ }
502
+
479
503
  async #searchMappings(params = {}, maxPages = this.maxSearchPages) {
480
504
  const endpoint = this.org ? `/orgs/${encodeURIComponent(this.org)}/mappings/` : '/mappings/';
481
505
  return await this.#fetchAllPages(endpoint, params, maxPages);
@@ -523,9 +547,16 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider {
523
547
  }
524
548
  }
525
549
 
526
- const discovered = await this.#resolveSourceCandidatesFromOcl(systemUrl);
527
- for (const item of discovered) {
528
- result.add(item);
550
+ // Ask OCL which repo holds this canonical. An authoritative answer makes the
551
+ // heuristic text search below unnecessary; without one we fall back to it.
552
+ const resolved = await this.#resolveViaReferenceResolver(systemUrl);
553
+ if (resolved) {
554
+ result.add(resolved);
555
+ } else {
556
+ const discovered = await this.#resolveSourceCandidatesFromOcl(systemUrl);
557
+ for (const item of discovered) {
558
+ result.add(item);
559
+ }
529
560
  }
530
561
 
531
562
  const out = Array.from(result);
@@ -533,6 +564,56 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider {
533
564
  return out;
534
565
  }
535
566
 
567
+ /**
568
+ * Resolve a canonical to its OCL repo via $resolveReference.
569
+ * Returns null when the resolver is disabled (no token), the endpoint is
570
+ * unavailable, or OCL cannot resolve the canonical -- in every case the caller
571
+ * falls back to the existing search.
572
+ */
573
+ async #resolveViaReferenceResolver(systemUrl) {
574
+ // Say why once, rather than per lookup: for a tokenless deployment this is the
575
+ // expected steady state, not an error.
576
+ if (!this.referenceResolver.isEnabled()) {
577
+ if (!this._resolverDisabledLogged) {
578
+ this._resolverDisabledLogged = true;
579
+ oclCmLog.info(
580
+ `$resolveReference not in use (${this.referenceResolver.disabledReason}); using source search`
581
+ );
582
+ }
583
+ return null;
584
+ }
585
+
586
+ let resolved;
587
+ try {
588
+ resolved = await this.referenceResolver.resolve(systemUrl);
589
+ } catch (error) {
590
+ oclCmLog.warn(`$resolveReference lookup failed for ${systemUrl}: ${error.message}`);
591
+ return null;
592
+ }
593
+
594
+ if (!resolved?.resolved || !resolved.repoUrl) {
595
+ oclCmLog.info(`$resolveReference did not resolve ${systemUrl}; falling back to source search`);
596
+ return null;
597
+ }
598
+
599
+ // Logged on success too: resolver and search return the same thing, so without
600
+ // this there is no way -- from outside or from the logs -- to tell which path ran.
601
+ oclCmLog.info(`$resolveReference resolved ${systemUrl} -> ${resolved.repoUrl}`);
602
+
603
+ // Keep the canonical<->repo caches coherent with the search path's bookkeeping.
604
+ // Index under what was asked, but record OCL's own canonical_url as the repo's
605
+ // canonical: echoing the request back would store whatever spelling the caller
606
+ // used rather than the repo's actual identity.
607
+ const canonicalKey = this.#norm(systemUrl);
608
+ if (!this._sourceUrlsByCanonical.has(canonicalKey)) {
609
+ this._sourceUrlsByCanonical.set(canonicalKey, new Set());
610
+ }
611
+ this._sourceUrlsByCanonical.get(canonicalKey).add(resolved.repoUrl);
612
+ this._canonicalBySourceUrl.set(this.#norm(resolved.repoUrl), resolved.canonical || systemUrl);
613
+
614
+ return resolved.repoUrl;
615
+ }
616
+
536
617
  async #resolveSourceCandidatesFromOcl(systemUrl) {
537
618
  const endpoint = this.org ? `/orgs/${encodeURIComponent(this.org)}/sources/` : '/sources/';
538
619
  const query = this.#queryTokenFromSystem(systemUrl);
@@ -573,17 +654,52 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider {
573
654
  }
574
655
 
575
656
  async #ensureCanonicalForSourceUrls(sourceUrls) {
657
+ // Deduplicate the repo paths that still need a canonical.
658
+ const pending = [];
659
+ const seen = new Set();
576
660
  for (const sourceUrl of sourceUrls || []) {
577
661
  const sourceKey = this.#norm(sourceUrl);
578
- if (!sourceKey || this._canonicalBySourceUrl.has(sourceKey)) {
662
+ if (!sourceKey || this._canonicalBySourceUrl.has(sourceKey) || seen.has(sourceKey)) {
579
663
  continue;
580
664
  }
581
-
582
665
  const sourcePath = String(sourceUrl || '').trim();
583
- if (!sourcePath.startsWith('/orgs/')) {
666
+ if (!isOclRepoPath(sourcePath)) {
584
667
  continue;
585
668
  }
669
+ seen.add(sourceKey);
670
+ pending.push(sourcePath);
671
+ }
672
+
673
+ if (pending.length === 0) {
674
+ return;
675
+ }
676
+
677
+ // One $resolveReference batch instead of one GET per source: the result
678
+ // carries the repo's canonical_url. Anything the batch cannot resolve falls
679
+ // through to the per-source GETs below, so behaviour without a token (or on
680
+ // failure) is unchanged.
681
+ if (this.referenceResolver.isEnabled()) {
682
+ try {
683
+ const results = await this.referenceResolver.resolveReferences(pending);
684
+ if (Array.isArray(results)) {
685
+ for (let i = 0; i < pending.length; i++) {
686
+ const r = results[i];
687
+ if (!r?.resolved || !r.canonical) {
688
+ continue;
689
+ }
690
+ const resolvedSourceUrl = r.repoUrl || pending[i];
691
+ this.#recordCanonicalForSource(pending[i], resolvedSourceUrl, r.canonical);
692
+ }
693
+ }
694
+ } catch (error) {
695
+ oclCmLog.warn(`$resolveReference batch for source canonicals failed: ${error.message}`);
696
+ }
697
+ }
586
698
 
699
+ for (const sourcePath of pending) {
700
+ if (this._canonicalBySourceUrl.has(this.#norm(sourcePath))) {
701
+ continue;
702
+ }
587
703
  try {
588
704
  const response = await this.httpClient.get(sourcePath);
589
705
  const source = response.data || {};
@@ -592,13 +708,7 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider {
592
708
  if (!canonical) {
593
709
  continue;
594
710
  }
595
-
596
- const canonicalKey = this.#norm(canonical);
597
- if (!this._sourceUrlsByCanonical.has(canonicalKey)) {
598
- this._sourceUrlsByCanonical.set(canonicalKey, new Set());
599
- }
600
- this._sourceUrlsByCanonical.get(canonicalKey).add(resolvedSourceUrl);
601
- this._canonicalBySourceUrl.set(this.#norm(resolvedSourceUrl), canonical);
711
+ this.#recordCanonicalForSource(sourcePath, resolvedSourceUrl, canonical);
602
712
  } catch (e) {
603
713
  // Ignore source lookup failures and continue resolving remaining sources.
604
714
  continue;
@@ -606,6 +716,18 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider {
606
716
  }
607
717
  }
608
718
 
719
+ #recordCanonicalForSource(requestedPath, resolvedSourceUrl, canonical) {
720
+ const canonicalKey = this.#norm(canonical);
721
+ if (!this._sourceUrlsByCanonical.has(canonicalKey)) {
722
+ this._sourceUrlsByCanonical.set(canonicalKey, new Set());
723
+ }
724
+ this._sourceUrlsByCanonical.get(canonicalKey).add(resolvedSourceUrl);
725
+ this._canonicalBySourceUrl.set(this.#norm(resolvedSourceUrl), canonical);
726
+ // Also index under the exact path we were asked about, so the "already
727
+ // resolved" checks hit even when OCL's repo url differs in shape.
728
+ this._canonicalBySourceUrl.set(this.#norm(requestedPath), canonical);
729
+ }
730
+
609
731
  #queryTokenFromSystem(systemUrl) {
610
732
  const raw = String(systemUrl || '').trim().replace(/\/+$/, '');
611
733
  if (!raw) {