fhirsmith 0.18.2 → 1.0.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 (203) hide show
  1. package/ACCESSIBILITY.md +119 -0
  2. package/CHANGELOG.md +149 -0
  3. package/CONFIGURATION.md +305 -0
  4. package/LICENSE +29 -0
  5. package/README.md +29 -122
  6. package/cluster.js +1220 -0
  7. package/cluster.md +707 -0
  8. package/config-template.json +77 -8
  9. package/extension-tracker/extension-tracker-template.html +5 -8
  10. package/extension-tracker/extension-tracker.js +0 -2
  11. package/folder/folder.js +7 -6
  12. package/ips/analysis.js +560 -0
  13. package/ips/ips-template.html +5 -8
  14. package/ips/ips.js +132 -59
  15. package/ips/nationality.js +120 -0
  16. package/ips/pipeline.js +74 -21
  17. package/ips/presenter.js +1 -1
  18. package/ips/readme.md +132 -25
  19. package/ips/render.js +466 -77
  20. package/ips/store.js +79 -22
  21. package/ips/structure.js +247 -0
  22. package/ips/terminology.js +1 -1
  23. package/library/body-limit.js +22 -0
  24. package/library/cache-generation.js +15 -0
  25. package/library/cgroup-memory.js +133 -1
  26. package/library/cluster-counts.js +121 -0
  27. package/library/cluster-events.js +153 -0
  28. package/library/cluster-metrics.js +193 -0
  29. package/library/cluster-status.js +468 -0
  30. package/library/config-reader.js +96 -0
  31. package/library/daily-report.js +100 -2
  32. package/library/html-server.js +27 -1
  33. package/library/i18nsupport.js +23 -1
  34. package/library/keep-alive.js +18 -0
  35. package/library/languages.js +17 -3
  36. package/library/logger.js +31 -0
  37. package/library/memory-guard.js +428 -0
  38. package/library/native-memory.js +52 -0
  39. package/library/npm-audit.js +51 -25
  40. package/library/openapi-doc.js +7 -5
  41. package/library/package-manager.js +8 -0
  42. package/library/role.js +197 -0
  43. package/library/server-credentials.js +169 -0
  44. package/library/sqlite-utilities.js +59 -0
  45. package/library/ssrf-guard.js +100 -0
  46. package/library/static-response-stats.js +16 -0
  47. package/library/version-utilities.js +50 -0
  48. package/library/worker-count.js +192 -0
  49. package/nginx.md +30 -11
  50. package/npmprojector/indexer.js +18 -10
  51. package/package.json +11 -8
  52. package/packages/feed-check-page.js +117 -0
  53. package/packages/feed-check.js +279 -0
  54. package/packages/npm-check.js +57 -3
  55. package/packages/npm-signing.js +219 -0
  56. package/packages/openapi.yaml +207 -1
  57. package/packages/package-crawler.js +322 -41
  58. package/packages/packages-template.html +6 -8
  59. package/packages/packages.js +604 -57
  60. package/packages/readme.md +113 -1
  61. package/publisher/publisher-template.html +2 -5
  62. package/publisher/publisher.js +121 -110
  63. package/publisher/readme.md +75 -0
  64. package/registry/crawler.js +84 -108
  65. package/registry/documentation/configuration.md +34 -0
  66. package/registry/registry-template.html +5 -8
  67. package/registry/registry.js +5 -4
  68. package/root-bare-template.html +7 -4
  69. package/root-template.html +5 -8
  70. package/server.js +398 -198
  71. package/shl/shl.js +38 -37
  72. package/static/a11y/a11y-check.js +832 -0
  73. package/static/a11y/axe.min.js +12 -0
  74. package/static/assets/css/bootstrap-fhir.css +15 -15
  75. package/static/assets/images/flags/au.svg +18 -0
  76. package/static/assets/images/flags/unknown.svg +11 -0
  77. package/static/assets/images/flags/us.svg +63 -0
  78. package/static/assets/images/icon_audit.png +0 -0
  79. package/static/assets/images/icon_clinical.png +0 -0
  80. package/static/assets/images/icon_document.png +0 -0
  81. package/static/assets/images/icon_external.png +0 -0
  82. package/static/assets/images/icon_medication.png +0 -0
  83. package/static/assets/images/icon_organization.png +0 -0
  84. package/static/assets/images/icon_other.png +0 -0
  85. package/static/assets/images/icon_person.png +0 -0
  86. package/static/assets/images/icon_section.png +0 -0
  87. package/stats-db.js +45 -0
  88. package/stats.js +197 -10
  89. package/testing/testing-template.html +5 -8
  90. package/token/nginx_passport_setup.md +1 -1
  91. package/token/security_guide.md +1 -1
  92. package/token/token-template.html +2 -5
  93. package/token/token.js +40 -36
  94. package/translations/Messages.properties +19 -0
  95. package/tx/README.md +154 -4
  96. package/tx/closure/closure-store.js +126 -3
  97. package/tx/cm/cm-database.js +69 -57
  98. package/tx/cm/cm-package.js +29 -1
  99. package/tx/cs/cs-api.js +284 -19
  100. package/tx/cs/cs-api.md +41 -4
  101. package/tx/cs/cs-areacode.js +1 -1
  102. package/tx/cs/cs-country.js +17 -5
  103. package/tx/cs/cs-cpt.js +33 -16
  104. package/tx/cs/cs-cs.js +148 -14
  105. package/tx/cs/cs-currency.js +1 -1
  106. package/tx/cs/cs-icd11.js +21 -13
  107. package/tx/cs/cs-icf.js +22 -3
  108. package/tx/cs/cs-lang.js +2 -2
  109. package/tx/cs/cs-loinc.js +185 -66
  110. package/tx/cs/cs-mimetypes.js +12 -2
  111. package/tx/cs/cs-ndc.js +40 -25
  112. package/tx/cs/cs-omop.js +54 -29
  113. package/tx/cs/cs-rxnorm.js +300 -196
  114. package/tx/cs/cs-snomed.js +608 -64
  115. package/tx/cs/cs-ucum.js +14 -4
  116. package/tx/cs/cs-unii.js +18 -14
  117. package/tx/cs/cs-virtual.js +210 -0
  118. package/tx/ecosystem-resolver.js +413 -0
  119. package/tx/html/cluster-dash-metrics.liquid +26 -0
  120. package/tx/html/cluster-home-metrics.liquid +31 -0
  121. package/tx/html/cluster-metrics-script.liquid +251 -0
  122. package/tx/html/codesystem-operations.liquid +3 -3
  123. package/tx/html/ecl-panel.liquid +1 -1
  124. package/tx/html/external-metrics.liquid +17 -0
  125. package/tx/html/operations-form.liquid +17 -17
  126. package/tx/html/search-form.liquid +11 -11
  127. package/tx/html/status-line.liquid +58 -0
  128. package/tx/html/status-panel.liquid +140 -0
  129. package/tx/html/status-tasks.liquid +25 -0
  130. package/tx/html/tx-template.html +2 -5
  131. package/tx/importers/atc-to-fhir.js +17 -16
  132. package/tx/importers/export-icd11-codesystem.js +3 -2
  133. package/tx/importers/import-icd11.js +4 -3
  134. package/tx/importers/import-loinc.module.js +118 -56
  135. package/tx/importers/import-ndc.module.js +56 -44
  136. package/tx/importers/import-rxnorm.module.js +118 -79
  137. package/tx/importers/import-sct.module.js +57 -58
  138. package/tx/importers/import-unii.module.js +37 -36
  139. package/tx/importers/subset-loinc.module.js +49 -48
  140. package/tx/importers/subset-rxnorm.module.js +29 -28
  141. package/tx/importers/terminal.js +18 -0
  142. package/tx/importers/tx-import-base.js +13 -12
  143. package/tx/importers/tx-import-settings.js +5 -4
  144. package/tx/importers/tx-import.js +53 -52
  145. package/tx/library/canonical-resource.js +49 -0
  146. package/tx/library/codesystem.js +94 -5
  147. package/tx/library/designations.js +45 -2
  148. package/tx/library/extensions.js +13 -7
  149. package/tx/library/implicit-valuesets.js +66 -0
  150. package/tx/library/renderer.js +77 -21
  151. package/tx/library/sqlite-utilities.js +2 -0
  152. package/tx/library/ucum-parsers.js +11 -1
  153. package/tx/library/wait-for-cache.js +43 -0
  154. package/tx/library.js +321 -22
  155. package/tx/openapi-operations.js +6 -4
  156. package/tx/operation-context.js +460 -99
  157. package/tx/params.js +25 -0
  158. package/tx/problems.js +3 -5
  159. package/tx/provider.js +278 -42
  160. package/tx/resource-cache-sqlite.js +1050 -0
  161. package/tx/sct/ecl.js +47 -6
  162. package/tx/sct/editions.js +39 -0
  163. package/tx/sct/expressions.js +10 -1
  164. package/tx/sct/load-config.js +107 -0
  165. package/tx/sct/mmap.js +109 -0
  166. package/tx/sct/structures.js +166 -13
  167. package/tx/sct/text-index.js +5 -1
  168. package/tx/syndication/syndication-feed.js +2 -2
  169. package/tx/tx-html.js +44 -8
  170. package/tx/tx.fhir.org.yml +8 -8
  171. package/tx/tx.js +282 -80
  172. package/tx/usage-tracker.js +39 -11
  173. package/tx/vs/vs-api.js +19 -0
  174. package/tx/vs/vs-database.js +386 -62
  175. package/tx/vs/vs-package.js +27 -4
  176. package/tx/vs/vs-vsac.js +648 -100
  177. package/tx/workers/batch-validate.js +37 -10
  178. package/tx/workers/batch.js +6 -1
  179. package/tx/workers/cache-control.js +15 -4
  180. package/tx/workers/closure.js +156 -13
  181. package/tx/workers/compare.js +8 -5
  182. package/tx/workers/expand.js +1173 -289
  183. package/tx/workers/metadata.js +35 -0
  184. package/tx/workers/search.js +53 -1
  185. package/tx/workers/supplement-filters.js +145 -0
  186. package/tx/workers/translate.js +169 -4
  187. package/tx/workers/validate.js +223 -119
  188. package/tx/workers/worker.js +123 -31
  189. package/tx/xversion/xv-valueset.js +7 -4
  190. package/utilities/submit-test-report.js +3 -1
  191. package/utilities/tx-stress-test.js +482 -0
  192. package/vcl/readme.md +37 -0
  193. package/vcl/vcl-parser.js +15 -0
  194. package/xig/readme.md +52 -0
  195. package/xig/xig-template.html +5 -8
  196. package/xig/xig.js +238 -422
  197. package/changelog-template.md +0 -22
  198. package/registry/test-runner.js +0 -237
  199. package/release.sh +0 -80
  200. package/test-scripts/repro-re2-wasm-leak.js +0 -34
  201. package/tsconfig.eslint.json +0 -9
  202. package/tx/html/dash-metrics.liquid +0 -147
  203. package/tx/html/home-metrics.liquid +0 -225
package/ips/ips.js CHANGED
@@ -39,7 +39,6 @@
39
39
  * @module ips/ips
40
40
  */
41
41
 
42
- const http = require('http');
43
42
  const express = require('express');
44
43
  const path = require('path');
45
44
  const fs = require('fs');
@@ -62,6 +61,9 @@ const { parseSearch, listQueryToSearch, capabilitySearchParams, compositionOf, p
62
61
  const render = require('./render');
63
62
  const { TxClient } = require('./tx-client');
64
63
  const { STEPS, stripChecks } = require('./pipeline');
64
+
65
+ // the extension definitions remembered from the validator (the analysis step)
66
+ const MAX_CACHED_EXTENSIONS = 5000;
65
67
  const { IpsValueSets } = require('./ips-valuesets');
66
68
  const aiClient = require('./ai-client');
67
69
  const { ShlClient } = require('./share');
@@ -82,54 +84,17 @@ const FHIR_JSON = 'application/fhir+json; charset=utf-8';
82
84
  const CODE_RE = /^[a-z0-9][a-z0-9-]{1,31}$/;
83
85
  // paths at the root that can't be project codes
84
86
  const { jurisdiction: jurisdictionOf } = require('./jurisdictions');
85
- const LOAD_IG_TIMEOUT_MS = 300000;
87
+ const { SNOMED_LINKABLE_EDITIONS } = require('../tx/sct/editions');
86
88
  const RESERVED = new Set(['login', 'logout', 'admin', 'metadata', 'openapi', 'static', 'assets', 'lookup', 'national-profiles']);
87
89
 
88
- /**
89
- * Ask the validator service to load a package (id#version) - POST /loadIG with {"ig": ...} as
90
- * the validator's LoadIGHTTPHandler takes it. fhir-validator-wrapper's own loadIG() (to 1.6.0
91
- * at least) sends the package id and version as query parameters with no body, which the
92
- * service refuses ("Error parsing JSON source: ... Eof at Line 1"), so it is only used for a
93
- * validator with no address of its own (as the tests' are).
94
- * @returns {Promise<Object>} the OperationOutcome the service answers with
95
- */
96
- function loadIG(validator, packageId, version) {
97
- if (!validator.baseUrl) {
98
- return validator.loadIG(packageId, version);
99
- }
100
- const body = JSON.stringify({ ig: `${packageId}#${version}` });
101
- const url = new URL(`${validator.baseUrl}/loadIG`);
102
- return new Promise((resolve, reject) => {
103
- const req = http.request({
104
- hostname: url.hostname, port: url.port, path: url.pathname, method: 'POST',
105
- headers: { 'Content-Type': 'application/json', 'Accept': 'application/fhir+json', 'Content-Length': Buffer.byteLength(body) }
106
- }, (res) => {
107
- const chunks = [];
108
- res.on('data', (c) => chunks.push(c));
109
- res.on('end', () => {
110
- const text = Buffer.concat(chunks).toString('utf8');
111
- try {
112
- resolve(JSON.parse(text));
113
- } catch (e) {
114
- reject(new Error(`The validator answered HTTP ${res.statusCode} with a response that is not JSON (${text.length} bytes)`));
115
- }
116
- });
117
- res.on('error', reject);
118
- });
119
- req.on('error', reject);
120
- // loading a package can mean downloading it, and its dependencies
121
- req.setTimeout(LOAD_IG_TIMEOUT_MS, () => req.destroy(new Error('The validator took too long to load the package')));
122
- req.end(body);
123
- });
124
- }
125
-
126
90
  /**
127
91
  * What's wrong with a national profile's details, or null: a jurisdiction (a country's ISO 3166
128
92
  * two-letter code, or a region's UN M49 three-digit code - see jurisdictions.js), a FHIR package id (lower case, at
129
- * least two parts separated by dots), a version (as package versions are written), and the
130
- * canonical URL (http or https) of the IG's profile on Bundle.
93
+ * least two parts separated by dots), a version (as package versions are written), the
94
+ * canonical URL (http or https) of the IG's profile on Bundle, and - optionally - the SNOMED CT
95
+ * edition the IG takes for granted, by its module id (one of SNOMED_LINKABLE_EDITIONS).
131
96
  */
132
- function nationalProfileProblem(jurisdiction, packageId, version, url) {
97
+ function nationalProfileProblem(jurisdiction, packageId, version, url, snomedEdition) {
133
98
  if (!jurisdictionOf(jurisdiction)) {
134
99
  return 'The jurisdiction must be a country, by its ISO 3166 two-letter code (e.g. AU), or a region, by its UN M49 three-digit code (e.g. 150 for Europe)';
135
100
  }
@@ -142,6 +107,9 @@ function nationalProfileProblem(jurisdiction, packageId, version, url) {
142
107
  if (url.length > 1024 || !/^https?:\/\/[^\s/?#]+(?:[/?#]\S*)?$/.test(url)) {
143
108
  return 'The profile must be the canonical URL (http or https) of the IG\'s profile on Bundle';
144
109
  }
110
+ if (snomedEdition && !SNOMED_LINKABLE_EDITIONS.has(snomedEdition)) {
111
+ return 'The SNOMED CT edition must be one of the editions offered (or none)';
112
+ }
145
113
  return null;
146
114
  }
147
115
 
@@ -687,11 +655,18 @@ class IpsModule {
687
655
  txLog: cfg.txLog || folders.filePath('logs', 'ips-validator-tx.log'),
688
656
  port: cfg.port || 8082,
689
657
  igs: Array.isArray(cfg.packages) ? cfg.packages : ['hl7.fhir.uv.ips#1.1.0'],
690
- timeout: cfg.timeout || 120000
658
+ timeout: cfg.timeout || 120000,
659
+ // lookupExtension(): an extension the validator doesn't have is looked for the way the
660
+ // validator does while validating - the package that defines it is found from the url,
661
+ // downloaded and loaded (validator 7.0.5 and later; fhir-validator-wrapper 1.7.0 and later)
662
+ autoLoadPackages: cfg.autoLoadPackages !== false
691
663
  };
692
664
  this.log.info(`IPS module: starting the FHIR validator${localJar ? ` (${localJar})` : ''} on port ${settings.port} (tx ${settings.txServer}; ${settings.igs.join(', ')})`);
693
665
  this.validatorStarting = this.validator.start(settings).then(() => {
694
666
  this.log.info('IPS module: the FHIR validator is running');
667
+ // documents wait for this too, so the analysis step - which looks up extensions before
668
+ // the national step would load the packages - finds what the national IGs define
669
+ return this.loadNationalPackages();
695
670
  }).catch(e => {
696
671
  this.log.error('IPS module: the FHIR validator failed to start: ' + e.message);
697
672
  this.validatorError = e.message;
@@ -700,6 +675,33 @@ class IpsModule {
700
675
  });
701
676
  }
702
677
 
678
+ /**
679
+ * Load every national profile's package into the validator (as the national step would), so
680
+ * that what they define is there from the start. A package that won't load is logged (and
681
+ * remembered) by loadNationalPackage(), and doesn't stop the others.
682
+ * @returns {Promise<void>}
683
+ */
684
+ async loadNationalPackages() {
685
+ if (!this.validator || !this.store) {
686
+ return;
687
+ }
688
+ const list = this.store.nationalProfiles();
689
+ if (list.length === 0) {
690
+ return;
691
+ }
692
+ const started = Date.now();
693
+ this.log.info(`IPS module: loading the packages of ${list.length} national profiles into the validator`);
694
+ // one at a time: the validator loads one package per request anyway
695
+ for (const np of list) {
696
+ try {
697
+ await this.loadNationalPackage(np.packageId, np.version);
698
+ } catch (e) {
699
+ // already logged
700
+ }
701
+ }
702
+ this.log.info(`IPS module: national profile packages loaded in ${Math.round((Date.now() - started) / 1000)}s`);
703
+ }
704
+
703
705
  /**
704
706
  * Load a national profile's package into the validator - once: a package that loaded, or
705
707
  * failed to (with why), is remembered until the national profiles are changed, so each
@@ -711,13 +713,15 @@ class IpsModule {
711
713
  const key = `${packageId}#${version}`;
712
714
  let p = this.nationalPackages.get(key);
713
715
  if (!p) {
714
- p = loadIG(this.validator, packageId, version).then(outcome => {
716
+ p = this.validator.loadIG(packageId, version).then(outcome => {
715
717
  const bad = (Array.isArray(outcome?.issue) ? outcome.issue : []).filter(i => i && (i.severity === 'error' || i.severity === 'fatal'));
716
718
  if (bad.length > 0 || outcome?.resourceType !== 'OperationOutcome') {
717
719
  const why = bad.map(i => i.details?.text || i.diagnostics || i.code).filter(Boolean).join('; ') || 'the validator did not return an OperationOutcome';
718
720
  throw new Error(`The validator could not load ${key}: ${why}`);
719
721
  }
720
722
  this.log.info(`IPS module: national profile package ${key} loaded into the validator`);
723
+ // the validator knows more now: extensions it couldn't find before may be there
724
+ this.forgetMissingExtensions();
721
725
  });
722
726
  p.catch(e => this.log.warn(`IPS module: ${e.message}`));
723
727
  this.nationalPackages.set(key, p);
@@ -757,6 +761,58 @@ class IpsModule {
757
761
  return null;
758
762
  }
759
763
 
764
+ /**
765
+ * Forget the extensions the validator couldn't find: it has loaded a package since, and may
766
+ * know them now. The ones it found are kept.
767
+ */
768
+ forgetMissingExtensions() {
769
+ if (this.extensionCache) {
770
+ for (const [url, answer] of this.extensionCache) {
771
+ if (answer === null) {
772
+ this.extensionCache.delete(url);
773
+ }
774
+ }
775
+ }
776
+ }
777
+
778
+ /**
779
+ * The definition of an extension, for the analysis step: {url, title, name, description},
780
+ * or null when it can't be found. It is asked of the validator (the wrapper's
781
+ * lookupCanonical(), the service's /canonical, which looks in the validator's context - and,
782
+ * with modules.ips.validator.autoLoadPackages (on by default), finds and loads the package that
783
+ * defines one it doesn't have).
784
+ * Answers are remembered (a bounded cache); a validator that isn't there, or that fails, or
785
+ * that predates /canonical, isn't an answer, and is asked again next time.
786
+ * @param {string} url
787
+ * @returns {Promise<Object|null>}
788
+ */
789
+ async lookupExtension(url) {
790
+ const v = this.validator;
791
+ if (!v || typeof v.lookupCanonical !== 'function' || typeof v.isRunning !== 'function' || !v.isRunning()) {
792
+ return null;
793
+ }
794
+ const cache = this.extensionCache || (this.extensionCache = new Map());
795
+ if (cache.has(url)) {
796
+ return cache.get(url);
797
+ }
798
+ let answer = null;
799
+ try {
800
+ const r = await v.lookupCanonical(url);
801
+ if (r) {
802
+ const str = (x) => (typeof x === 'string' ? x : '');
803
+ answer = { url: str(r.url), title: str(r.title), name: str(r.name), description: str(r.description).substring(0, 1000) };
804
+ }
805
+ } catch (e) {
806
+ this.log.warn(`IPS module: looking up extension ${url} failed: ${e.message}`);
807
+ return null;
808
+ }
809
+ if (cache.size >= MAX_CACHED_EXTENSIONS) {
810
+ cache.delete(cache.keys().next().value);
811
+ }
812
+ cache.set(url, answer);
813
+ return answer;
814
+ }
815
+
760
816
  /**
761
817
  * Whether the pipeline can run now: not while the validator is still starting, nor -
762
818
  * for a while after start-up - while a terminology server on this server isn't there
@@ -932,20 +988,29 @@ class IpsModule {
932
988
  }
933
989
 
934
990
  /**
935
- * The paragraph for the server's daily report: pages served, documents
936
- * received in the period, and how many are stored.
991
+ * The paragraph for the server's daily report: pages served (and how many were about documents - a
992
+ * document's page, in any of its views), new documents and how many are stored, and the
993
+ * processing pipeline - how many times it was started, and how many of those didn't finish
994
+ * (it failed, or the server stopped while it ran).
937
995
  */
938
996
  dailyReport({since, now}) {
939
997
  const {plural} = require('../library/daily-report');
998
+ const was = (n) => n === 1 ? 'was' : 'were';
940
999
  const sentences = [];
941
1000
  if (this.stats) {
942
1001
  const counts = this.stats.countsSince(since, now);
943
- sentences.push(`${plural(counts.total, 'page')} ${counts.total === 1 ? 'was' : 'were'} served.`);
1002
+ const docs = counts.operations.read || 0;
1003
+ sentences.push(`${plural(counts.total, 'page')} ${was(counts.total)} served${counts.total ? `, ${docs} of them about documents` : ''}.`);
944
1004
  }
945
1005
  if (this.store) {
946
1006
  const received = this.store.countReceived(since, now);
947
1007
  const stored = this.store.count();
948
- sentences.push(`${plural(received, 'IPS document')} ${received === 1 ? 'was' : 'were'} received; ${plural(stored, 'document')} ${stored === 1 ? 'is' : 'are'} stored.`);
1008
+ sentences.push(`${plural(received, 'new document')} ${was(received)} received; ${plural(stored, 'document')} ${stored === 1 ? 'is' : 'are'} stored.`);
1009
+ const runs = this.store.countRuns(since, now);
1010
+ const unfinished = runs.failed + runs.interrupted;
1011
+ const why = [runs.failed ? `${runs.failed} failed` : '', runs.interrupted ? `${runs.interrupted} interrupted by a shutdown` : ''].filter(Boolean).join(', ');
1012
+ sentences.push(`The processing pipeline was started ${plural(runs.started, 'time')}; ${unfinished} didn't finish${why ? ` (${why})` : ''}` +
1013
+ `${runs.running ? `, and ${runs.running} ${runs.running === 1 ? 'is' : 'are'} still running` : ''}.`);
949
1014
  } else {
950
1015
  sentences.push('The document store is not open.');
951
1016
  }
@@ -1405,12 +1470,13 @@ class IpsModule {
1405
1470
  const list = this.store.nationalProfiles();
1406
1471
  res.status(status);
1407
1472
  if (!wantsHtml(req) && !message) {
1408
- return res.json(list.map(np => ({ jurisdiction: np.jurisdiction, packageId: np.packageId, version: np.version, url: np.url })));
1473
+ return res.json(list.map(np => ({ jurisdiction: np.jurisdiction, packageId: np.packageId, version: np.version, url: np.url,
1474
+ ...(np.snomedEdition ? { snomedEdition: np.snomedEdition } : {}) })));
1409
1475
  }
1410
1476
  this.sendHtml(res, 'National Profiles', render.renderNationalProfiles(list, this.ctx(req, res), message), Date.now(), req);
1411
1477
  }
1412
1478
 
1413
- /** Add (id null) or change a national profile, from the form: packageId, version, url. */
1479
+ /** Add (id null) or change a national profile, from the form: jurisdiction, packageId, version, url, snomedEdition (optional). */
1414
1480
  saveNationalProfile(req, res, id) {
1415
1481
  const field = (name) => (typeof req.body[name] === 'string' ? req.body[name].trim() : '');
1416
1482
  const packageId = field('packageId');
@@ -1418,7 +1484,9 @@ class IpsModule {
1418
1484
  const url = field('url');
1419
1485
  // a country code is upper case; a region's is digits
1420
1486
  const jurisdiction = field('jurisdiction').toUpperCase();
1421
- const problem = nationalProfileProblem(jurisdiction, packageId, version, url);
1487
+ // the edition's module id; empty for none
1488
+ const snomedEdition = field('snomedEdition') || null;
1489
+ const problem = nationalProfileProblem(jurisdiction, packageId, version, url, snomedEdition);
1422
1490
  if (problem) {
1423
1491
  return this.sendNationalProfiles(req, res, problem, 400);
1424
1492
  }
@@ -1427,20 +1495,24 @@ class IpsModule {
1427
1495
  if (existing) {
1428
1496
  return this.sendNationalProfiles(req, res, `There is already a national profile for ${packageId}#${version}`, 409);
1429
1497
  }
1430
- this.store.createNationalProfile(packageId, version, url, jurisdiction);
1431
- this.log.info(`IPS module: national profile ${packageId}#${version} (${jurisdiction}) added (${url})`);
1498
+ this.store.createNationalProfile(packageId, version, url, jurisdiction, snomedEdition);
1499
+ this.log.info(`IPS module: national profile ${packageId}#${version} (${jurisdiction}${snomedEdition ? `, SNOMED CT ${snomedEdition}` : ''}) added (${url})`);
1432
1500
  } else {
1433
1501
  const n = /^\d{1,15}$/.test(id) ? parseInt(id, 10) : -1;
1434
1502
  if (existing && existing.id !== n) {
1435
1503
  return this.sendNationalProfiles(req, res, `There is already a national profile for ${packageId}#${version}`, 409);
1436
1504
  }
1437
- if (!this.store.updateNationalProfile(n, packageId, version, url, jurisdiction)) {
1505
+ if (!this.store.updateNationalProfile(n, packageId, version, url, jurisdiction, snomedEdition)) {
1438
1506
  return this.sendNationalProfiles(req, res, 'That national profile no longer exists', 404);
1439
1507
  }
1440
- this.log.info(`IPS module: national profile ${packageId}#${version} (${jurisdiction}) saved (${url})`);
1508
+ this.log.info(`IPS module: national profile ${packageId}#${version} (${jurisdiction}${snomedEdition ? `, SNOMED CT ${snomedEdition}` : ''}) saved (${url})`);
1441
1509
  }
1442
- // a package that failed to load is tried again
1510
+ // a package that failed to load is tried again; the packages are loaded now (in the
1511
+ // background), so the analysis step finds what they define
1443
1512
  this.nationalPackages = new Map();
1513
+ if (this.validator && this.validator.isRunning()) {
1514
+ this.loadNationalPackages().catch(e => this.log.warn(`IPS module: loading the national profile packages failed: ${e.message}`));
1515
+ }
1444
1516
  res.redirect(req.baseUrl + '/national-profiles');
1445
1517
  }
1446
1518
 
@@ -1812,7 +1884,7 @@ class IpsModule {
1812
1884
  if (!row) {
1813
1885
  return htmlServer.sendErrorResponse(res, TEMPLATE, new Error(`No document with id '${req.params.id}'`), 404);
1814
1886
  }
1815
- const view = render.VIEWS.some(([v]) => v === req.query.view) ? req.query.view : render.VIEWS[0][0];
1887
+ const { view, narrative } = render.viewOf(req.query);
1816
1888
  row.results = {};
1817
1889
  for (const step of this.steps) {
1818
1890
  row.results[step.name] = this.store.readResult(row.id, step.name);
@@ -1822,7 +1894,8 @@ class IpsModule {
1822
1894
  steps: this.steps.map(st => st.name),
1823
1895
  level: typeof req.query.level === 'string' ? req.query.level : undefined,
1824
1896
  np: typeof req.query.np === 'string' ? req.query.np : undefined,
1825
- problemsOnly: req.query.problems === '1'
1897
+ problemsOnly: req.query.problems === '1',
1898
+ narrative
1826
1899
  };
1827
1900
  return this.sendHtml(res, `${req.project.name}: Document ${row.id}`, render.renderDocument(req.project, row, view, ctx), start, req);
1828
1901
  }
@@ -0,0 +1,120 @@
1
+ //
2
+ // Copyright 2026, Health Intersections Pty Ltd (http://www.healthintersections.com.au)
3
+ //
4
+ // Licensed under BSD-3: https://opensource.org/license/bsd-3-clause
5
+ //
6
+
7
+ /**
8
+ * The nationality step - the first processing step: which nation an IPS document is from,
9
+ * as far as the document itself says. Few national IGs give a way to tell, so this is a
10
+ * short list of signs, to grow as more are found:
11
+ *
12
+ * US the Bundle claims the US Patient Summary profile (Bundle-us-pcs), or a US Core
13
+ * extension is used anywhere in the document
14
+ * AU the Bundle claims the AU Patient Summary profile (au-ps-bundle)
15
+ *
16
+ * The result is { jurisdiction, reasons: [{ jurisdiction, text }] }: jurisdiction is the
17
+ * nation's ISO 3166 two-letter code, or null when it is unknown - nothing points to a nation,
18
+ * or the signs point to more than one (then the reasons say which).
19
+ *
20
+ * @module ips/nationality
21
+ */
22
+
23
+ const US_CORE_EXTENSIONS = 'http://hl7.org/fhir/us/core/StructureDefinition/';
24
+ // how many extension urls a reason names
25
+ const MAX_NAMED = 3;
26
+
27
+ /** The profiles a resource claims (meta.profile), without their versions. */
28
+ function profilesOf(resource) {
29
+ const p = resource?.meta?.profile;
30
+ return Array.isArray(p) ? p.filter(x => typeof x === 'string').map(x => x.split('|')[0]) : [];
31
+ }
32
+
33
+ /** Every extension url in the document, at any depth, each once. */
34
+ function extensionUrls(node, found = new Set()) {
35
+ if (Array.isArray(node)) {
36
+ for (const n of node) {
37
+ extensionUrls(n, found);
38
+ }
39
+ } else if (node && typeof node === 'object') {
40
+ for (const [k, v] of Object.entries(node)) {
41
+ if ((k === 'extension' || k === 'modifierExtension') && Array.isArray(v)) {
42
+ for (const e of v) {
43
+ if (e && typeof e.url === 'string') {
44
+ found.add(e.url);
45
+ }
46
+ }
47
+ }
48
+ extensionUrls(v, found);
49
+ }
50
+ }
51
+ return found;
52
+ }
53
+
54
+ /** A Bundle profile that says which nation a document is from. */
55
+ function profileRule(jurisdiction, url, name) {
56
+ return (bundle) => (profilesOf(bundle).includes(url) ? `the Bundle claims the ${name} profile (${url})` : null);
57
+ }
58
+
59
+ const RULES = [
60
+ { jurisdiction: 'US', test: profileRule('US', 'http://hl7.org/fhir/us/pcs/StructureDefinition/Bundle-us-pcs', 'US Patient Summary') },
61
+ {
62
+ jurisdiction: 'US',
63
+ test: (bundle, urls) => {
64
+ const core = [...urls].filter(u => u.startsWith(US_CORE_EXTENSIONS)).sort();
65
+ if (core.length === 0) {
66
+ return null;
67
+ }
68
+ const named = core.slice(0, MAX_NAMED).map(u => u.substring(US_CORE_EXTENSIONS.length)).join(', ');
69
+ return `US Core extension${core.length === 1 ? ' is' : 's are'} used (${named}${core.length > MAX_NAMED ? ` and ${core.length - MAX_NAMED} more` : ''})`;
70
+ }
71
+ },
72
+ { jurisdiction: 'AU', test: profileRule('AU', 'http://hl7.org.au/fhir/ps/StructureDefinition/au-ps-bundle', 'AU Patient Summary') }
73
+ ];
74
+
75
+ /**
76
+ * Which nation a document is from.
77
+ * @param {Object} bundle
78
+ * @returns {{jurisdiction: string|null, reasons: Array<{jurisdiction: string, text: string}>}}
79
+ */
80
+ function determineNationality(bundle) {
81
+ const urls = extensionUrls(bundle);
82
+ const reasons = [];
83
+ for (const rule of RULES) {
84
+ const text = rule.test(bundle, urls);
85
+ if (text) {
86
+ reasons.push({ jurisdiction: rule.jurisdiction, text });
87
+ }
88
+ }
89
+ const nations = [...new Set(reasons.map(r => r.jurisdiction))];
90
+ return { jurisdiction: nations.length === 1 ? nations[0] : null, reasons };
91
+ }
92
+
93
+ /** What the result says, in a sentence. */
94
+ function nationalityText(result) {
95
+ if (!result) {
96
+ return null;
97
+ }
98
+ const reasons = Array.isArray(result.reasons) ? result.reasons : [];
99
+ if (result.jurisdiction) {
100
+ return `From ${result.jurisdiction}: ${reasons.map(r => r.text).join('; ')}`;
101
+ }
102
+ if (reasons.length > 0) {
103
+ const nations = [...new Set(reasons.map(r => r.jurisdiction))];
104
+ return `Nationality unknown: the document has signs of more than one nation (${nations.map(n => `${n}: ${reasons.filter(r => r.jurisdiction === n).map(r => r.text).join('; ')}`).join(' - ')})`;
105
+ }
106
+ return 'Nationality unknown: nothing in the document says which nation it is from (only US and Australian documents can be recognised so far)';
107
+ }
108
+
109
+ const nationalityStep = {
110
+ name: 'nationality',
111
+ async run(row, project, module) {
112
+ const started = Date.now();
113
+ const result = determineNationality(row.bundle);
114
+ module.store.saveResult(row.id, 'nationality', { ms: Date.now() - started, ...result });
115
+ module.store.setNationality(row.id, result.jurisdiction);
116
+ return `nationality: ${result.jurisdiction || 'unknown'}`;
117
+ }
118
+ };
119
+
120
+ module.exports = { determineNationality, nationalityText, nationalityStep, RULES };
package/ips/pipeline.js CHANGED
@@ -9,14 +9,16 @@
9
9
  * One process works through the queue (ips.js: processQueue), and runs these steps over
10
10
  * each document, in this order:
11
11
  *
12
- * 1. translate translate CodeableConcepts that fail the terminology check to codes that pass it
13
- * 2. narrative generate the narrative with the IPS presenter (presenter.js), into the analysed copy
14
- * 3. validation validate the (translated) document with the FHIR validator (fhir-validator-wrapper)
15
- * 4. national validate it against each national profile (the national IGs' profiles on Bundle)
16
- * 5. terminology check each CodeableConcept against the value sets for international exchange
17
- * 6. piqi measure the quality of the document with PIQI (not yet)
18
- * 7. profiles check the document against each clinical profile (profiles.js)
19
- * 8. ai assess the document for consistency and its terminology with an AI service (ai-client.js)
12
+ * 0. nationality which nation the document is from, as far as it says (nationality.js)
13
+ * 1. analysis look at each resource and section for what a reader should know (analysis.js)
14
+ * 2. translate translate CodeableConcepts that fail the terminology check to codes that pass it
15
+ * 3. narrative generate the narrative with the IPS presenter (presenter.js), into the analysed copy
16
+ * 4. validation validate the (translated) document with the FHIR validator (fhir-validator-wrapper)
17
+ * 5. national validate it against each national profile (the national IGs' profiles on Bundle)
18
+ * 6. terminology check each CodeableConcept against the value sets for international exchange
19
+ * 7. piqi measure the quality of the document with PIQI (not yet)
20
+ * 8. profiles check the document against each clinical profile (profiles.js)
21
+ * 9. ai assess the document for consistency and its terminology with an AI service (ai-client.js)
20
22
  *
21
23
  * Each step is { name, run(row, project, module) -> result }. A step keeps what it finds in
22
24
  * one of two places: as a result stored against the document (store.saveResult - the
@@ -35,6 +37,8 @@ const { Presenter } = require('./presenter');
35
37
  const ai = require('./ai-client');
36
38
  const { evaluateAsync } = require('./fhirpath-ips');
37
39
  const { checkNarrative } = require('../library/narrative-check');
40
+ const { analysisStep } = require('./analysis');
41
+ const { nationalityStep } = require('./nationality');
38
42
 
39
43
  const IPS_BUNDLE_PROFILE = 'http://hl7.org/fhir/uv/ips/StructureDefinition/Bundle-uv-ips';
40
44
  const VALIDATE_TIMEOUT_MS = 120000;
@@ -100,14 +104,13 @@ const validation = {
100
104
  }
101
105
  const cfg = module.config.validator || {};
102
106
  const profiles = Array.isArray(cfg.profiles) ? cfg.profiles : [IPS_BUNDLE_PROFILE];
103
- // the validator's own IPS checks (code systems and value sets the IPS expects), unless
104
- // the configuration turns them off
105
- const checkIpsCodes = cfg.checkIpsCodes !== false;
107
+ // no checkIpsCodes: validator 7.0.4 dropped its IPS code checks, and refuses the parameter
108
+ // ("Unable to process param checkIpsCodes"), failing every validation
106
109
  const started = Date.now();
107
110
  // fhir-validator-wrapper 1.5.0 or later: earlier versions sent the profiles in a form the
108
111
  // validator service refuses
109
112
  // the analysed copy: the translations are in it, and the validator should see them
110
- const outcome = await v.validate(JSON.stringify(row.analysed || row.bundle), { profiles, checkIpsCodes, timeout: VALIDATE_TIMEOUT_MS });
113
+ const outcome = await v.validate(JSON.stringify(row.analysed || row.bundle), { profiles, timeout: VALIDATE_TIMEOUT_MS });
111
114
  if (!outcome || outcome.resourceType !== 'OperationOutcome') {
112
115
  throw new Error('The validator did not return an OperationOutcome');
113
116
  }
@@ -148,8 +151,6 @@ const national = {
148
151
  if (!v.isRunning()) {
149
152
  throw new Error('The validator is not running');
150
153
  }
151
- const cfg = module.config.validator || {};
152
- const checkIpsCodes = cfg.checkIpsCodes !== false;
153
154
  const doc = JSON.stringify(row.analysed || row.bundle);
154
155
  const profiles = [];
155
156
  for (const np of list) {
@@ -157,7 +158,7 @@ const national = {
157
158
  const t0 = Date.now();
158
159
  try {
159
160
  await module.loadNationalPackage(np.packageId, np.version);
160
- const outcome = await v.validate(doc, { profiles: [np.url], checkIpsCodes, timeout: VALIDATE_TIMEOUT_MS });
161
+ const outcome = await v.validate(doc, { profiles: [np.url], timeout: VALIDATE_TIMEOUT_MS });
161
162
  if (!outcome || outcome.resourceType !== 'OperationOutcome') {
162
163
  throw new Error('The validator did not return an OperationOutcome');
163
164
  }
@@ -546,6 +547,56 @@ function translationOf(coding) {
546
547
  };
547
548
  }
548
549
 
550
+ const SNOMED_CT = 'http://snomed.info/sct';
551
+
552
+ /**
553
+ * The SNOMED CT edition a document's codes are taken to be from when they don't say: the
554
+ * edition of a national profile for the nation the document is from (the nationality step).
555
+ * Only the translate step uses it - the terminology check takes the codes as they are, so a
556
+ * code that is fine as it is passes whatever its edition, and one whose edition says otherwise
557
+ * doesn't.
558
+ * @returns {{edition: string, packageId: string, version: string, jurisdiction: string}|null}
559
+ */
560
+ function defaultSnomedEdition(module, row) {
561
+ const jurisdiction = module.store.readResult(row.id, 'nationality')?.result?.jurisdiction;
562
+ if (!jurisdiction) {
563
+ return null;
564
+ }
565
+ const np = module.store.nationalProfiles().find(p => p.jurisdiction === jurisdiction && p.snomedEdition);
566
+ return np ? { edition: np.snomedEdition, packageId: np.packageId, version: np.version, jurisdiction } : null;
567
+ }
568
+
569
+ /** A concept with its SNOMED CT codings that have no version given the edition (a copy; the concept is unchanged). */
570
+ function withSnomedEdition(concept, edition) {
571
+ if (!edition || !Array.isArray(concept.coding)) {
572
+ return concept;
573
+ }
574
+ return { ...concept, coding: concept.coding.map(c => (c && c.system === SNOMED_CT && !c.version ? { ...c, version: `${SNOMED_CT}/${edition}` } : c)) };
575
+ }
576
+
577
+ /** The SNOMED CT edition a version names (its module id, without the release date), or the version itself. */
578
+ function editionOf(system, version) {
579
+ if (system === SNOMED_CT && typeof version === 'string') {
580
+ const m = version.match(/^http:\/\/snomed\.info\/x?sct\/(\d+)(?:\/version\/\d+)?$/);
581
+ if (m) {
582
+ return m[1];
583
+ }
584
+ }
585
+ return version;
586
+ }
587
+
588
+ /**
589
+ * Whether a concept already has a coding: the same system and code, and - when both say - the
590
+ * same version. A version that differs makes it a different coding: the same SNOMED CT code in
591
+ * the International Edition is what a translation from a national edition is for, even though
592
+ * the code is the same (SNOMED CT versions are compared by edition, not release). A coding with
593
+ * no version already stands for the code, whatever the translation's version.
594
+ */
595
+ function hasCoding(codings, coding) {
596
+ return codings.some(x => x && x.system === coding.system && x.code === coding.code &&
597
+ (!x.version || !coding.version || editionOf(x.system, x.version) === editionOf(coding.system, coding.version)));
598
+ }
599
+
549
600
  /** A CodeableConcept as the server should see it: without our extensions. */
550
601
  function withoutOurExtensions(concept) {
551
602
  const c = structuredClone(concept);
@@ -586,6 +637,8 @@ const translate = {
586
637
  const budget = { left: MAX_CHECKS_PER_DOCUMENT };
587
638
  const cache = module.txCache || (module.txCache = new Map());
588
639
  const started = Date.now();
640
+ // SNOMED CT codes with no version are taken to be from the edition of the document's nation
641
+ const defaultEdition = defaultSnomedEdition(module, row);
589
642
  // what happened to each concept, for the translation view
590
643
  const entries = [];
591
644
  // the same resource in two sections, and the same concept in two places: once
@@ -619,11 +672,11 @@ const translate = {
619
672
  } else if (budget.left <= 0) {
620
673
  translation = { matches: [], failed: true, check, message: `not translated - the document has more codes than are checked (${MAX_CHECKS_PER_DOCUMENT})` };
621
674
  } else {
622
- const cacheKey = `translate|${key}`;
675
+ const cacheKey = `translate|${defaultEdition ? defaultEdition.edition : ''}|${key}`;
623
676
  translation = cache.get(cacheKey);
624
677
  if (!translation) {
625
678
  budget.left--;
626
- translation = await translateConcept(module.tx, valueSets, withoutOurExtensions(c.concept), target);
679
+ translation = await translateConcept(module.tx, valueSets, withSnomedEdition(withoutOurExtensions(c.concept), defaultEdition?.edition), target);
627
680
  if (!translation.failed) {
628
681
  if (cache.size >= MAX_CACHED_ANSWERS) {
629
682
  cache.delete(cache.keys().next().value);
@@ -653,7 +706,7 @@ const translate = {
653
706
  }
654
707
  let added = 0;
655
708
  for (const m of translation.matches) {
656
- if (codings.some(x => x && x.system === m.coding.system && x.code === m.coding.code)) {
709
+ if (hasCoding(codings, m.coding)) {
657
710
  entry.added.push({ ...m, present: true });
658
711
  continue; // already there
659
712
  }
@@ -678,7 +731,7 @@ const translate = {
678
731
  entry.message = translation.matches.length > 0 ? 'The translation gave only codings the concept already has' : (translation.message || 'The terminology server has no translation');
679
732
  }
680
733
  }
681
- module.store.saveResult(row.id, 'translate', { ms: Date.now() - started, server: module.tx.url, counts, entries });
734
+ module.store.saveResult(row.id, 'translate', { ms: Date.now() - started, server: module.tx.url, counts, entries, ...(defaultEdition ? { defaultEdition } : {}) });
682
735
  return `translate: ${counts.translated} of ${counts.tried} concepts translated (${counts.codings} codings added)` +
683
736
  (counts.failed ? `, ${counts.failed} not translated (see the log)` : '');
684
737
  }
@@ -1064,10 +1117,10 @@ const aiStep = {
1064
1117
  };
1065
1118
 
1066
1119
  /** The steps, in order. Only the ones that exist yet. */
1067
- const STEPS = [translate, narrative, validation, national, terminology, profilesStep, aiStep];
1120
+ const STEPS = [nationalityStep, analysisStep, translate, narrative, validation, national, terminology, profilesStep, aiStep];
1068
1121
 
1069
1122
  module.exports = {
1070
- STEPS, translate, narrative, validation, national, terminology, profiles: profilesStep, ai: aiStep, checkConstraint, valueSetsIn, lookupDisplay, checkConcept, documentForAi, assessmentOf, aiSystemPrompt, AI_SYSTEM_PROMPT, checkOf, addCheck, stripChecks, translateConcept, translationOf,
1123
+ STEPS, analysis: analysisStep, translate, narrative, validation, national, terminology, profiles: profilesStep, ai: aiStep, checkConstraint, valueSetsIn, lookupDisplay, checkConcept, documentForAi, assessmentOf, aiSystemPrompt, AI_SYSTEM_PROMPT, checkOf, addCheck, stripChecks, translateConcept, translationOf, defaultSnomedEdition, withSnomedEdition, hasCoding,
1071
1124
  validateCodeInValueSet, validateCodeInSystem, issueCounts, issueSummary,
1072
1125
  IPS_BUNDLE_PROFILE, CHECK_EXTENSION, TRANSLATION_EXTENSION, MAX_CHECKS_PER_DOCUMENT, MAX_CACHED_ANSWERS
1073
1126
  };
package/ips/presenter.js CHANGED
@@ -969,6 +969,6 @@ const GENERIC = {
969
969
  };
970
970
 
971
971
  module.exports = {
972
- Presenter, COLUMNS, GENERIC, describeResource, NONE,
972
+ Presenter, COLUMNS, GENERIC, describeResource, codeText, NONE,
973
973
  dateText, periodText, quantityText, rangeText, humanNameText, addressText, identifierText, contactPointText, annotationText
974
974
  };