fhirsmith 0.18.1 → 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 (214) hide show
  1. package/ACCESSIBILITY.md +119 -0
  2. package/CHANGELOG.md +190 -9
  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 +331 -33
  15. package/ips/jurisdictions.js +65 -0
  16. package/ips/nationality.js +120 -0
  17. package/ips/pipeline.js +131 -17
  18. package/ips/presenter.js +1 -1
  19. package/ips/profile-editor.js +283 -4
  20. package/ips/readme.md +170 -20
  21. package/ips/render.js +769 -109
  22. package/ips/scripts.js +46 -0
  23. package/ips/store.js +123 -11
  24. package/ips/structure.js +247 -0
  25. package/ips/terminology.js +1 -1
  26. package/library/body-limit.js +22 -0
  27. package/library/cache-generation.js +15 -0
  28. package/library/cgroup-memory.js +133 -1
  29. package/library/cluster-counts.js +121 -0
  30. package/library/cluster-events.js +153 -0
  31. package/library/cluster-metrics.js +193 -0
  32. package/library/cluster-status.js +468 -0
  33. package/library/config-reader.js +96 -0
  34. package/library/daily-report.js +100 -2
  35. package/library/html-server.js +27 -1
  36. package/library/i18nsupport.js +23 -1
  37. package/library/keep-alive.js +18 -0
  38. package/library/languages.js +17 -3
  39. package/library/logger.js +31 -0
  40. package/library/memory-guard.js +428 -0
  41. package/library/native-memory.js +52 -0
  42. package/library/npm-audit.js +51 -25
  43. package/library/openapi-doc.js +7 -5
  44. package/library/package-manager.js +8 -0
  45. package/library/role.js +197 -0
  46. package/library/server-credentials.js +169 -0
  47. package/library/sqlite-utilities.js +59 -0
  48. package/library/ssrf-guard.js +100 -0
  49. package/library/static-response-stats.js +16 -0
  50. package/library/version-utilities.js +50 -0
  51. package/library/worker-count.js +192 -0
  52. package/nginx.md +30 -11
  53. package/npmprojector/indexer.js +18 -10
  54. package/package.json +11 -8
  55. package/packages/feed-check-page.js +117 -0
  56. package/packages/feed-check.js +279 -0
  57. package/packages/npm-check.js +57 -3
  58. package/packages/npm-signing.js +219 -0
  59. package/packages/openapi.yaml +207 -1
  60. package/packages/package-crawler.js +322 -41
  61. package/packages/packages-template.html +6 -8
  62. package/packages/packages.js +604 -57
  63. package/packages/readme.md +113 -1
  64. package/publisher/publisher-template.html +2 -5
  65. package/publisher/publisher.js +121 -110
  66. package/publisher/readme.md +75 -0
  67. package/registry/api.js +179 -1
  68. package/registry/crawler.js +86 -108
  69. package/registry/documentation/api.md +68 -2
  70. package/registry/documentation/configuration.md +34 -0
  71. package/registry/documentation/internals.md +4 -1
  72. package/registry/documentation/readme.md +3 -2
  73. package/registry/model.js +8 -0
  74. package/registry/openapi.yaml +84 -0
  75. package/registry/registry-template.html +5 -8
  76. package/registry/registry.js +58 -6
  77. package/root-bare-template.html +7 -4
  78. package/root-template.html +5 -8
  79. package/server.js +398 -198
  80. package/shl/shl.js +38 -37
  81. package/static/a11y/a11y-check.js +832 -0
  82. package/static/a11y/axe.min.js +12 -0
  83. package/static/assets/css/bootstrap-fhir.css +15 -15
  84. package/static/assets/images/flags/au.svg +18 -0
  85. package/static/assets/images/flags/unknown.svg +11 -0
  86. package/static/assets/images/flags/us.svg +63 -0
  87. package/static/assets/images/icon_audit.png +0 -0
  88. package/static/assets/images/icon_clinical.png +0 -0
  89. package/static/assets/images/icon_document.png +0 -0
  90. package/static/assets/images/icon_external.png +0 -0
  91. package/static/assets/images/icon_medication.png +0 -0
  92. package/static/assets/images/icon_organization.png +0 -0
  93. package/static/assets/images/icon_other.png +0 -0
  94. package/static/assets/images/icon_person.png +0 -0
  95. package/static/assets/images/icon_section.png +0 -0
  96. package/stats-db.js +45 -0
  97. package/stats.js +197 -10
  98. package/testing/testing-template.html +5 -8
  99. package/token/nginx_passport_setup.md +1 -1
  100. package/token/security_guide.md +1 -1
  101. package/token/token-template.html +2 -5
  102. package/token/token.js +40 -36
  103. package/translations/Messages.properties +19 -0
  104. package/tx/README.md +158 -4
  105. package/tx/closure/closure-store.js +126 -3
  106. package/tx/cm/cm-database.js +69 -57
  107. package/tx/cm/cm-package.js +50 -16
  108. package/tx/cs/cs-api.js +284 -19
  109. package/tx/cs/cs-api.md +41 -4
  110. package/tx/cs/cs-areacode.js +1 -1
  111. package/tx/cs/cs-country.js +17 -5
  112. package/tx/cs/cs-cpt.js +33 -16
  113. package/tx/cs/cs-cs.js +199 -24
  114. package/tx/cs/cs-currency.js +1 -1
  115. package/tx/cs/cs-icd11.js +21 -13
  116. package/tx/cs/cs-icf.js +22 -3
  117. package/tx/cs/cs-lang.js +2 -2
  118. package/tx/cs/cs-loinc.js +193 -67
  119. package/tx/cs/cs-mimetypes.js +12 -2
  120. package/tx/cs/cs-ndc.js +40 -25
  121. package/tx/cs/cs-omop.js +54 -29
  122. package/tx/cs/cs-rxnorm.js +300 -196
  123. package/tx/cs/cs-snomed.js +861 -95
  124. package/tx/cs/cs-ucum.js +14 -4
  125. package/tx/cs/cs-unii.js +18 -14
  126. package/tx/cs/cs-virtual.js +210 -0
  127. package/tx/ecosystem-resolver.js +413 -0
  128. package/tx/html/cluster-dash-metrics.liquid +26 -0
  129. package/tx/html/cluster-home-metrics.liquid +31 -0
  130. package/tx/html/cluster-metrics-script.liquid +251 -0
  131. package/tx/html/codesystem-operations.liquid +3 -3
  132. package/tx/html/ecl-panel.liquid +1 -1
  133. package/tx/html/external-metrics.liquid +17 -0
  134. package/tx/html/operations-form.liquid +17 -17
  135. package/tx/html/search-form.liquid +11 -11
  136. package/tx/html/status-line.liquid +58 -0
  137. package/tx/html/status-panel.liquid +140 -0
  138. package/tx/html/status-tasks.liquid +25 -0
  139. package/tx/html/tx-template.html +2 -5
  140. package/tx/importers/atc-to-fhir.js +17 -16
  141. package/tx/importers/export-icd11-codesystem.js +3 -2
  142. package/tx/importers/import-icd11.js +4 -3
  143. package/tx/importers/import-loinc.module.js +118 -56
  144. package/tx/importers/import-ndc.module.js +56 -44
  145. package/tx/importers/import-rxnorm.module.js +118 -79
  146. package/tx/importers/import-sct.module.js +125 -61
  147. package/tx/importers/import-unii.module.js +37 -36
  148. package/tx/importers/subset-loinc.module.js +49 -48
  149. package/tx/importers/subset-rxnorm.module.js +29 -28
  150. package/tx/importers/terminal.js +18 -0
  151. package/tx/importers/tx-import-base.js +13 -12
  152. package/tx/importers/tx-import-settings.js +5 -4
  153. package/tx/importers/tx-import.js +53 -52
  154. package/tx/library/canonical-resource.js +49 -0
  155. package/tx/library/codesystem.js +94 -5
  156. package/tx/library/designations.js +45 -2
  157. package/tx/library/extensions.js +13 -7
  158. package/tx/library/implicit-valuesets.js +66 -0
  159. package/tx/library/renderer.js +77 -21
  160. package/tx/library/sqlite-utilities.js +2 -0
  161. package/tx/library/tho-precedence.js +40 -0
  162. package/tx/library/ucum-parsers.js +11 -1
  163. package/tx/library/wait-for-cache.js +43 -0
  164. package/tx/library.js +321 -22
  165. package/tx/openapi-operations.js +6 -4
  166. package/tx/operation-context.js +460 -99
  167. package/tx/params.js +25 -0
  168. package/tx/problems.js +3 -5
  169. package/tx/provider.js +314 -67
  170. package/tx/resource-cache-sqlite.js +1050 -0
  171. package/tx/sct/documentation.md +45 -21
  172. package/tx/sct/ecl.js +81 -17
  173. package/tx/sct/editions.js +39 -0
  174. package/tx/sct/expressions.js +10 -1
  175. package/tx/sct/load-config.js +107 -0
  176. package/tx/sct/mmap.js +109 -0
  177. package/tx/sct/structures.js +175 -15
  178. package/tx/sct/text-index.js +5 -1
  179. package/tx/syndication/syndication-feed.js +2 -2
  180. package/tx/tx-html.js +44 -8
  181. package/tx/tx.fhir.org.yml +8 -8
  182. package/tx/tx.js +282 -80
  183. package/tx/usage-tracker.js +39 -11
  184. package/tx/vs/vs-api.js +19 -0
  185. package/tx/vs/vs-database.js +386 -62
  186. package/tx/vs/vs-package.js +44 -17
  187. package/tx/vs/vs-vsac.js +649 -101
  188. package/tx/workers/batch-validate.js +37 -10
  189. package/tx/workers/batch.js +6 -1
  190. package/tx/workers/cache-control.js +15 -4
  191. package/tx/workers/closure.js +156 -13
  192. package/tx/workers/compare.js +8 -5
  193. package/tx/workers/expand.js +1173 -289
  194. package/tx/workers/metadata.js +35 -0
  195. package/tx/workers/search.js +53 -1
  196. package/tx/workers/supplement-filters.js +145 -0
  197. package/tx/workers/translate.js +169 -4
  198. package/tx/workers/validate.js +223 -119
  199. package/tx/workers/worker.js +123 -31
  200. package/tx/xversion/xv-valueset.js +7 -4
  201. package/utilities/submit-test-report.js +3 -1
  202. package/utilities/tx-stress-test.js +482 -0
  203. package/vcl/readme.md +37 -0
  204. package/vcl/vcl-parser.js +15 -0
  205. package/xig/readme.md +52 -0
  206. package/xig/xig-template.html +5 -8
  207. package/xig/xig.js +238 -422
  208. package/changelog-template.md +0 -22
  209. package/registry/test-runner.js +0 -237
  210. package/release.sh +0 -80
  211. package/test-scripts/repro-re2-wasm-leak.js +0 -34
  212. package/tsconfig.eslint.json +0 -9
  213. package/tx/html/dash-metrics.liquid +0 -147
  214. package/tx/html/home-metrics.liquid +0 -225
@@ -16,9 +16,11 @@ const {ConceptMap} = require("../library/conceptmap");
16
16
  const {ECLLexer, ECLParser, ECLNodeType, ECLTokenType} = require("../sct/ecl");
17
17
  const {Issue} = require("../library/operation-outcome");
18
18
  const {debugLog} = require("../operation-context");
19
- const {editionName, editionCode} = require("../sct/editions");
19
+ const {editionName, editionCode, SNOMED_LINKABLE_EDITIONS} = require("../sct/editions");
20
20
  const {SnomedTextIndex, rankMatches, filterTokens} = require("../sct/text-index");
21
21
  const Logger = require("../../library/logger");
22
+ const {Language} = require("../../library/languages");
23
+ const regexUtilities = require("../../library/regex-utilities");
22
24
 
23
25
  // Symbol key under which a resolved ECL filter analysis is memoised directly on
24
26
  // the ValueSet compose.include.filter element (`fc`). Symbol-keyed and defined
@@ -30,6 +32,7 @@ const Logger = require("../../library/logger");
30
32
  // _cacheEcl): a wildcard's materialised form depends on forIteration and is not
31
33
  // shared - it simply recomputes.
32
34
  const SNOMED_FILTER_ANALYSIS = Symbol('snomedFilterAnalysis');
35
+ const SNOMED_DISPLAY_REGEX = Symbol('snomedDisplayRegex');
33
36
 
34
37
  /**
35
38
  * Alternate identifier schemes (sct2_Identifier) that FHIRsmith knows how to present:
@@ -37,7 +40,7 @@ const SNOMED_FILTER_ANALYSIS = Symbol('snomedFilterAnalysis');
37
40
  * returned as. An identifier in an unknown scheme is kept but not reported.
38
41
  */
39
42
  const IDENTIFIER_SCHEMES = {
40
- '30051010000102': { system: 'http://loinc.org', property: 'loinc' } // |LOINC code identifier| (LOINC Extension)
43
+ '30051010000102': { system: 'http://loinc.org', property: 'loinc', alias: 'LOINC' } // |LOINC code identifier| (LOINC Extension)
41
44
  };
42
45
 
43
46
  /** The $expand / ECL-free filter on alternate identifiers: mapped:<scheme> exists true|false */
@@ -58,6 +61,8 @@ const SCT_LANGUAGE_CODES = {
58
61
  9: 'cs'
59
62
  };
60
63
 
64
+ const SCT_LANGUAGE_BYTES = Object.fromEntries(Object.entries(SCT_LANGUAGE_CODES).map(([b, c]) => [c, Number(b)]));
65
+
61
66
  // ECL dialect aliases (ECL specification, appendix "Dialect aliases") to the
62
67
  // language reference set they denote. Used by {{ D dialect = en-gb }}.
63
68
  const ECL_DIALECT_ALIASES = {
@@ -77,6 +82,7 @@ const ECL_DIALECT_ALIASES = {
77
82
  };
78
83
 
79
84
  // Context kinds matching Pascal enum
85
+
80
86
  const SnomedProviderContextKind = {
81
87
  CODE: 0,
82
88
  EXPRESSION: 1
@@ -146,14 +152,54 @@ class SnomedFilterContext {
146
152
  }
147
153
 
148
154
  class SnomedPrep {
149
- constructor() {
155
+ constructor(languages = null) {
150
156
  this.filters = [];
157
+ this.languages = languages; // the languages the request named, or null
151
158
  }
152
159
  }
153
160
 
154
161
  /**
155
162
  * Core SNOMED services providing access to structures and expression processing
156
163
  */
164
+ /**
165
+ * A matcher for an ECL wild: term: case-insensitive, anchored at both ends, '*' matching any
166
+ * run of characters (a run of '*'s the same as one) and every other character literal.
167
+ *
168
+ * No regex: a native RegExp built from '*' -> '.*' backtracks for a time that grows as the
169
+ * term length to the power of the number of '*'s (one $expand burned 90 s of CPU), and re2js,
170
+ * though linear, is 10-70 times slower than this over a whole edition's descriptions. The
171
+ * pieces between the '*'s are literal, so the term must start with the first and end with
172
+ * the last, and each one in between is found with indexOf after the one before - taking the
173
+ * leftmost match of each is always right for such a pattern. Linear, with no backtracking.
174
+ *
175
+ * @param {string} value - the wild: term
176
+ * @returns {function(string): boolean}
177
+ */
178
+ function wildTermMatcher(value) {
179
+ const parts = value.toLowerCase().split(/\*+/);
180
+ const first = parts[0];
181
+ const last = parts[parts.length - 1];
182
+ if (parts.length === 1) {
183
+ return (term) => term.toLowerCase() === first;
184
+ }
185
+ const middle = parts.slice(1, -1).filter(p => p.length > 0);
186
+ return (term) => {
187
+ const t = term.toLowerCase();
188
+ if (t.length < first.length + last.length || !t.startsWith(first)) {
189
+ return false;
190
+ }
191
+ let pos = first.length;
192
+ for (const part of middle) {
193
+ const i = t.indexOf(part, pos);
194
+ if (i < 0) {
195
+ return false;
196
+ }
197
+ pos = i + part.length;
198
+ }
199
+ return t.length - pos >= last.length && t.endsWith(last);
200
+ };
201
+ }
202
+
157
203
  class SnomedServices {
158
204
  constructor(sharedData) {
159
205
  // Core data structures
@@ -206,6 +252,39 @@ class SnomedServices {
206
252
  this._mrcmRangeCache = new Map();
207
253
 
208
254
  this.#loadIdentifiers(sharedData.identifiers || []);
255
+ this.#loadConcreteValues(sharedData.concreteValues);
256
+ }
257
+
258
+ /**
259
+ * Index the concrete value relationships (cache version 19: rows of
260
+ * [sourceId, typeId, group, value], value as written in RF2 - "#500", "#5.5",
261
+ * a quoted string, or true/false) by source concept index, for the ECL
262
+ * evaluator. `concreteValues` stays null when the cache predates them, which
263
+ * is how a concrete-value refinement knows to report itself as unsupported
264
+ * rather than answering with nothing.
265
+ */
266
+ #loadConcreteValues(rows) {
267
+ this.concreteValues = null;
268
+ if (!rows) return;
269
+ this.concreteValues = new Map();
270
+ for (const [sourceId, typeId, group, raw] of rows) {
271
+ const src = this.concepts.findConcept(sourceId);
272
+ const type = this.concepts.findConcept(typeId);
273
+ if (!src.found || !type.found) continue;
274
+ let value;
275
+ if (/^#-?\d+(\.\d+)?$/.test(raw)) {
276
+ value = { kind: 'number', value: Number(raw.substring(1)), decimal: raw.includes('.') };
277
+ } else if (/^".*"$/.test(raw)) {
278
+ value = { kind: 'string', value: raw.substring(1, raw.length - 1) };
279
+ } else if (/^(true|false)$/i.test(raw)) {
280
+ value = { kind: 'boolean', value: raw.toLowerCase() === 'true' };
281
+ } else {
282
+ value = { kind: 'string', value: raw };
283
+ }
284
+ const list = this.concreteValues.get(src.index) || [];
285
+ list.push({ relType: type.index, group: group || 0, concrete: value });
286
+ this.concreteValues.set(src.index, list);
287
+ }
209
288
  }
210
289
 
211
290
  /**
@@ -626,6 +705,9 @@ class SnomedServices {
626
705
  }
627
706
  }
628
707
 
708
+ // The concept's active outbound relationships. Inactive rows are imported (they are in the
709
+ // cache) but ignored altogether, as agreed with the SNOMED leads: a retired row no longer
710
+ // describes the concept, so no filter, lookup or refinement may see it.
629
711
  getConceptRelationships(reference) {
630
712
  try {
631
713
  const concept = this.concepts.getConcept(reference);
@@ -633,7 +715,7 @@ class SnomedServices {
633
715
 
634
716
  if (relRef === 0) return [];
635
717
 
636
- return this.refs.getReferences(relRef) || [];
718
+ return (this.refs.getReferences(relRef) || []).filter(relIdx => this.relationships.isActive(relIdx));
637
719
  } catch (error) {
638
720
  return [];
639
721
  }
@@ -806,6 +888,35 @@ class SnomedServices {
806
888
  return result;
807
889
  }
808
890
 
891
+ // descendent-leaf (R5): the concept's descendants that have no children of their own
892
+ filterDescendentLeaf(id) {
893
+ const result = new SnomedFilterContext();
894
+ const conceptResult = this.concepts.findConcept(id);
895
+
896
+ if (!conceptResult.found) {
897
+ throw new Error(`The SNOMED CT Concept ${id} is not known`);
898
+ }
899
+
900
+ result.descendants = this.getConceptDescendants(conceptResult.index)
901
+ .filter(ref => this.getConceptChildren(ref).length === 0);
902
+ return result;
903
+ }
904
+
905
+ // is-not-a (R5): every concept except the concept itself and its descendants - the
906
+ // same population as the ECL * MINUS << id
907
+ filterIsNotA(id, opContext = null) {
908
+ const result = new SnomedFilterContext();
909
+ const conceptResult = this.concepts.findConcept(id);
910
+
911
+ if (!conceptResult.found) {
912
+ throw new Error(`The SNOMED CT Concept ${id} is not known`);
913
+ }
914
+
915
+ const excluded = new Set([conceptResult.index, ...this.getConceptDescendants(conceptResult.index)]);
916
+ result.descendants = this._eclEnumerateAllConcepts(opContext).filter(ref => !excluded.has(ref));
917
+ return result;
918
+ }
919
+
809
920
  filterChildOf(id = true) {
810
921
  const result = new SnomedFilterContext();
811
922
  const conceptResult = this.concepts.findConcept(id);
@@ -853,10 +964,25 @@ class SnomedServices {
853
964
  }
854
965
 
855
966
 
967
+ // concept in A,B,C: any of the listed concepts; a listed reference set stands for its
968
+ // members. Members are kept as reference set member records ({ref}), which is what every
969
+ // consumer of set.members reads, so a plain concept is wrapped the same way; and each
970
+ // concept appears once, however many times it is named or reached.
856
971
  filterIn(idList) {
857
972
  const result = new SnomedFilterContext();
858
- let members = [];
973
+ const members = [];
974
+ const seen = new Set();
975
+ const add = (member) => {
976
+ if (!seen.has(member.ref)) {
977
+ seen.add(member.ref);
978
+ members.push(member);
979
+ }
980
+ };
859
981
  for (let id of idList.split(',')) {
982
+ id = id.trim();
983
+ if (!id) {
984
+ continue;
985
+ }
860
986
  const conceptResult = this.concepts.findConcept(id);
861
987
 
862
988
  if (!conceptResult.found) {
@@ -865,9 +991,11 @@ class SnomedServices {
865
991
 
866
992
  const refSetIndex = this.getConceptRefSet(conceptResult.index, false);
867
993
  if (refSetIndex === 0) {
868
- members.push(conceptResult.index);
994
+ add({ ref: conceptResult.index });
869
995
  } else {
870
- members.push(...this.refSetMembers.getMembers(refSetIndex));
996
+ for (const member of this.refSetMembers.getMembers(refSetIndex) || []) {
997
+ add(member);
998
+ }
871
999
  }
872
1000
  }
873
1001
  result.members = members;
@@ -912,14 +1040,16 @@ class SnomedServices {
912
1040
  * hierarchy operators on *, ^ and bracketed sets (applied per member)
913
1041
  * AND / OR / MINUS compound expressions
914
1042
  * !!> / !!< (top / bottom of set)
915
- * refinements (= against an expression, attribute AND/OR/",", groups,
916
- * attribute and group cardinality, attribute names that are expressions),
1043
+ * refinements (= and != against an expression, reversed (R) attributes,
1044
+ * attribute AND/OR/",", groups, attribute and group cardinality,
1045
+ * attribute names that are expressions),
917
1046
  * dotted expressions, {{ C ... }} concept filters, {{ D ... }} description
918
- * filters, comments
1047
+ * filters, {{ + HISTORY... }} history supplements, concrete (numeric /
1048
+ * string / boolean) values in refinements (cache version 19+), comments
1049
+ *
1050
+ * alternate identifiers (LOINC#code, from the identifier scheme aliases)
919
1051
  *
920
- * Everything else (reverse attributes, != and concrete (numeric/string/
921
- * boolean) values in refinements, alternate identifiers, reference set field
922
- * selection on ^, {{ + ... }} history supplements) throws an informative
1052
+ * Everything else (reference set field selection on ^) throws an informative
923
1053
  * error which is reported as UNSUPPORTED_ECL.
924
1054
  */
925
1055
 
@@ -1068,9 +1198,6 @@ class SnomedServices {
1068
1198
  // ({{ M ... }}) are handled in _evalMemberOf; concept filters ({{ C ... }})
1069
1199
  // and description filters ({{ D ... }}) are applied to the result below.
1070
1200
  if (node.filters && node.filters.length > 0) {
1071
- if (node.filters.some(fc => fc.kind === '+')) {
1072
- throw new Error('ECL history supplements ({{ + ... }}) are not supported');
1073
- }
1074
1201
  const inner = { ...node, filters: undefined };
1075
1202
  let ctx = this._evalSubExpression(inner, opContext);
1076
1203
  const conceptFilters = node.filters.filter(fc => fc.kind === 'C');
@@ -1080,12 +1207,26 @@ class SnomedServices {
1080
1207
  for (const fc of node.filters.filter(fc => fc.kind === 'D')) {
1081
1208
  ctx = this._applyDescriptionFilter(ctx, fc.filters, opContext);
1082
1209
  }
1210
+ for (const fc of node.filters.filter(fc => fc.kind === '+')) {
1211
+ ctx = this._applyHistorySupplement(ctx, fc, opContext);
1212
+ }
1083
1213
  return ctx;
1084
1214
  }
1085
1215
 
1086
- // Alternate identifiers (ECL 2.1 "scheme#code")
1216
+ // Alternate identifiers (ECL 2.1 "scheme#code"): the concept(s) that carry
1217
+ // the code in that identifier scheme, then the operator applies to them as
1218
+ // to any set. The scheme is an alias from the ECL specification (LOINC) or
1219
+ // the identifier scheme concept's own id.
1087
1220
  if (focus.type === ECLNodeType.ALT_IDENTIFIER) {
1088
- throw new Error(`ECL alternate identifiers (${focus.scheme}#${focus.code}) are not supported - no alternate identifier reference sets are loaded`);
1221
+ const set = this._eclAltIdentifier(focus);
1222
+ if (!operator) {
1223
+ const result = new SnomedFilterContext();
1224
+ result.descendants = set;
1225
+ return result;
1226
+ }
1227
+ const base = new SnomedFilterContext();
1228
+ base.descendants = set;
1229
+ return this._eclApplyOperatorToSet(base, operator, opContext);
1089
1230
  }
1090
1231
 
1091
1232
  // Top / bottom of set: the members of the focus set with no ancestor
@@ -1160,6 +1301,32 @@ class SnomedServices {
1160
1301
  return result;
1161
1302
  };
1162
1303
 
1304
+ /**
1305
+ * Resolve an ECL alternate identifier (scheme#code) to concept indexes. The
1306
+ * scheme is a known alias (IDENTIFIER_SCHEMES[].alias, case-insensitive) or
1307
+ * an identifier scheme concept id. An unknown scheme, or a code nobody has,
1308
+ * is an invalid expression - the same standing as an unknown concept.
1309
+ */
1310
+ _eclAltIdentifier = function (node) {
1311
+ let schemeId = null;
1312
+ if (/^\d{6,18}$/.test(node.scheme)) {
1313
+ schemeId = node.scheme;
1314
+ } else {
1315
+ for (const [id, def] of Object.entries(IDENTIFIER_SCHEMES)) {
1316
+ if (def.alias && def.alias.toLowerCase() === node.scheme.toLowerCase()) schemeId = id;
1317
+ }
1318
+ }
1319
+ const invalid = (msg) => { const e = new Error(msg); e.eclInvalid = true; return e; };
1320
+ if (!schemeId) {
1321
+ throw invalid(`The alternate identifier scheme "${node.scheme}" is not known`);
1322
+ }
1323
+ const concepts = this.getConceptsForIdentifier(schemeId, node.code);
1324
+ if (concepts.length === 0) {
1325
+ throw invalid(`No concept has the alternate identifier ${node.scheme}#${node.code}`);
1326
+ }
1327
+ return concepts.map(id => this.concepts.findConcept(id)).filter(r => r.found).map(r => r.index);
1328
+ };
1329
+
1163
1330
  /**
1164
1331
  * !!> / !!< : keep the concepts in `set` that have no ancestor (top) or no
1165
1332
  * descendant (bottom) that is also in `set`. Walks the stated parents of each
@@ -1317,6 +1484,9 @@ class SnomedServices {
1317
1484
  }
1318
1485
  const refsetConcepts = this._eclResolveSet(this._evalECLNode(memberOfNode.refSet, opContext), opContext);
1319
1486
  const memberFilters = this._eclPrepareMemberFilters(memberOfNode.memberFilters, opContext);
1487
+ const rs = memberOfNode.refSet;
1488
+ const explicit = rs.type === ECLNodeType.CONCEPT_REFERENCE ||
1489
+ (rs.type === ECLNodeType.SUB_EXPRESSION_CONSTRAINT && !rs.operator && rs.focus.type === ECLNodeType.CONCEPT_REFERENCE);
1320
1490
 
1321
1491
  const members = new Set();
1322
1492
  for (const refsetIdx of refsetConcepts) {
@@ -1334,9 +1504,12 @@ class SnomedServices {
1334
1504
  }
1335
1505
  const memberList = this.refSetMembers.getMembers(membersRef) || [];
1336
1506
  // A reference set whose members are descriptions (a language reference
1337
- // set, say) has no concept members to return. Say so rather than
1338
- // answering with an empty set, which would read as "no members".
1339
- if (memberList.length > 0 && !memberList.some(m => m.kind === 0)) {
1507
+ // set, say) has no concept members to return. When it was named
1508
+ // explicitly, say so rather than answering with an empty set, which would
1509
+ // read as "no members". When it merely falls inside a computed operand
1510
+ // (^ *, ^ (< 900000000000455006)) it contributes nothing and the rest of
1511
+ // the operand still counts.
1512
+ if (explicit && memberList.length > 0 && !memberList.some(m => m.kind === 0)) {
1340
1513
  throw new Error(`The members of reference set ${this.concepts.getConceptId(refsetIdx)} are descriptions, not concepts, so ^ cannot be evaluated for it`);
1341
1514
  }
1342
1515
  for (const m of memberList) {
@@ -1356,6 +1529,82 @@ class SnomedServices {
1356
1529
  return result;
1357
1530
  };
1358
1531
 
1532
+ /**
1533
+ * Apply a history supplement ({{ + HISTORY... }}): add to the result the
1534
+ * inactive concepts that the selected historical association reference sets
1535
+ * associate with a concept in the result (the association rows whose
1536
+ * targetComponentId is in the result).
1537
+ *
1538
+ * Profiles (ECL 2.x): MIN = SAME AS; MOD = SAME AS, WAS A, REPLACED BY,
1539
+ * PARTIALLY EQUIVALENT TO; MAX (and a bare HISTORY) = every reference set
1540
+ * under 900000000000522004 |Historical association|; HISTORY (expr) = the
1541
+ * reference sets the expression yields. A reference set the edition does not
1542
+ * carry simply contributes nothing.
1543
+ */
1544
+ _applyHistorySupplement = function (ctx, hs, opContext) {
1545
+ const HISTORICAL_ASSOCIATION = '900000000000522004';
1546
+ const PROFILES = {
1547
+ MIN: ['900000000000527005'],
1548
+ MOD: ['900000000000527005', '900000000000528000', '900000000000526001', '1186924009']
1549
+ };
1550
+ let refsetIdxs;
1551
+ if (hs.profile === 'EXPLICIT') {
1552
+ refsetIdxs = this._eclResolveSet(this._evalECLNode(hs.refsets, opContext), opContext);
1553
+ } else if (hs.profile === 'MAX') {
1554
+ const root = this.concepts.findConcept(HISTORICAL_ASSOCIATION);
1555
+ refsetIdxs = root.found ? this._eclToIndexArray(this.filterIsA(HISTORICAL_ASSOCIATION, false)) : [];
1556
+ } else {
1557
+ refsetIdxs = PROFILES[hs.profile].map(id => this.concepts.findConcept(id)).filter(r => r.found).map(r => r.index);
1558
+ }
1559
+
1560
+ const base = this._eclResolveSet(ctx, opContext);
1561
+ const inResult = new Set(base);
1562
+ const added = new Set();
1563
+ for (const refsetIdx of refsetIdxs) {
1564
+ if (opContext) opContext.deadCheck('ecl:history');
1565
+ const byTarget = this._historyIndex(refsetIdx);
1566
+ if (!byTarget) continue;
1567
+ for (const [target, sources] of byTarget) {
1568
+ if (inResult.has(target)) {
1569
+ for (const src of sources) {
1570
+ if (!inResult.has(src)) added.add(src);
1571
+ }
1572
+ }
1573
+ }
1574
+ }
1575
+ const result = new SnomedFilterContext();
1576
+ result.descendants = base.concat([...added]);
1577
+ return result;
1578
+ };
1579
+
1580
+ /**
1581
+ * target concept index -> [referenced (inactive) concept indexes] for one
1582
+ * association reference set, built once per edition. null when the concept
1583
+ * is not a reference set with concept-valued members.
1584
+ */
1585
+ _historyIndex = function (refsetIdx) {
1586
+ this._historyIndexes = this._historyIndexes || new Map();
1587
+ if (this._historyIndexes.has(refsetIdx)) {
1588
+ return this._historyIndexes.get(refsetIdx);
1589
+ }
1590
+ let index = null;
1591
+ const refSet = this._findRefSetRecord(refsetIdx);
1592
+ if (refSet && refSet.membersByRef && refSet.membersByRef !== 0xFFFFFFFF) {
1593
+ for (const m of this.refSetMembers.getMembers(refSet.membersByRef) || []) {
1594
+ if (m.kind !== 0 || !m.values) continue;
1595
+ const values = this.refs.getReferences(m.values);
1596
+ // targetComponentId is the first additional field, stored as (value, type) with type 1 = concept
1597
+ if (!values || values.length < 2 || values[1] !== 1) continue;
1598
+ index = index || new Map();
1599
+ const list = index.get(values[0]) || [];
1600
+ list.push(m.ref);
1601
+ index.set(values[0], list);
1602
+ }
1603
+ }
1604
+ this._historyIndexes.set(refsetIdx, index);
1605
+ return index;
1606
+ };
1607
+
1359
1608
  /**
1360
1609
  * Apply {{ C ... }} concept filters to a result: each concept is tested on
1361
1610
  * its own active flag, module, effectiveTime and definition status. Several
@@ -1546,8 +1795,7 @@ class SnomedServices {
1546
1795
  */
1547
1796
  _eclTermMatcher = function (v) {
1548
1797
  if (v.kind === 'wild') {
1549
- const re = new RegExp('^' + v.value.split('*').map(p => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('.*') + '$', 'i');
1550
- return (term) => re.test(term);
1798
+ return wildTermMatcher(v.value);
1551
1799
  }
1552
1800
  const words = v.value.toLowerCase().split(/\s+/).filter(w => w.length > 0);
1553
1801
  return (term) => {
@@ -1855,17 +2103,11 @@ class SnomedServices {
1855
2103
  * @returns {boolean}
1856
2104
  */
1857
2105
  _attributeMatches = function (rels, attr, groupFilter, opContext) {
1858
- if (attr.reverse) {
1859
- throw new Error('ECL reverse attributes (R) are not yet supported');
1860
- }
1861
2106
  if (!attr.comparison) {
1862
2107
  throw new Error('ECL attribute without a comparison is not supported');
1863
2108
  }
1864
- if (attr.comparison.type !== ECLNodeType.EXPRESSION_COMPARISON) {
1865
- throw new Error('ECL concrete values (numeric, string and boolean attribute values) are not supported - concrete value relationships are not loaded');
1866
- }
1867
- if (attr.comparison.operator !== ECLTokenType.EQUALS) {
1868
- throw new Error('ECL != in refinements is not yet supported');
2109
+ if (attr.comparison.type !== ECLNodeType.EXPRESSION_COMPARISON && !this.concreteValues) {
2110
+ throw new Error('ECL concrete values (numeric, string and boolean attribute values) cannot be evaluated - this edition was imported without its concrete value relationships');
1869
2111
  }
1870
2112
 
1871
2113
  const count = this._countAttributeMatches(rels, attr, groupFilter, opContext);
@@ -1895,15 +2137,30 @@ class SnomedServices {
1895
2137
  // fresh per request, so memoising on the node is safe within a request.
1896
2138
  let resolved = attr._eclResolved;
1897
2139
  if (!resolved) {
1898
- resolved = {
1899
- types: this._eclAttributeTypes(attr.name, opContext),
1900
- valueSet: new Set(this._eclResolveSet(this._evalECLNode(attr.comparison.value, opContext), opContext))
1901
- };
2140
+ resolved = { types: this._eclAttributeTypes(attr.name, opContext) };
2141
+ if (attr.comparison.type === ECLNodeType.EXPRESSION_COMPARISON) {
2142
+ resolved.valueSet = new Set(this._eclResolveSet(this._evalECLNode(attr.comparison.value, opContext), opContext));
2143
+ } else {
2144
+ resolved.concrete = this._eclConcreteMatcher(attr.comparison);
2145
+ }
1902
2146
  attr._eclResolved = resolved;
1903
2147
  }
1904
2148
  const types = resolved.types;
1905
2149
  const valueSet = resolved.valueSet;
1906
2150
 
2151
+ if (resolved.concrete) {
2152
+ // A concrete-value comparison: only the concrete rows count, each
2153
+ // occurrence being a (group, value) pair
2154
+ const matched = new Set();
2155
+ for (const rel of rels) {
2156
+ if (!rel.concrete) continue;
2157
+ if (!types.any && !types.types.has(rel.relType)) continue;
2158
+ if (groupFilter !== null && rel.group !== groupFilter) continue;
2159
+ if (resolved.concrete(rel.concrete)) matched.add(rel.group + ':' + JSON.stringify(rel.concrete.value));
2160
+ }
2161
+ return matched.size;
2162
+ }
2163
+
1907
2164
  // ECL cardinality counts OCCURRENCES of a matching attribute, per role group
1908
2165
  // — not distinct values. ECL runs against the necessary normal form, where a
1909
2166
  // role group has no redundant attributes, so the same (type, value) in two
@@ -1912,13 +2169,26 @@ class SnomedServices {
1912
2169
  // form, not to de-duplicating across groups). We therefore count distinct
1913
2170
  // (group, target) pairs: within a group filter this is the matching values
1914
2171
  // in that group; ungrouped it is the total occurrences across all groups.
2172
+ //
2173
+ // "!=" counts the occurrences whose value is NOT in the set (so "A != X"
2174
+ // matches a concept with at least one A whose value is something other than
2175
+ // X, and "[0..0] A != X" one whose every A is X).
2176
+ //
2177
+ // A reversed attribute ("R A = X") is matched against the concept's INBOUND
2178
+ // relationships: the concept is the value of an A attribute on some concept
2179
+ // in X. _conceptRelRecords supplies those tagged `inbound`; the records built
2180
+ // for a post-coordinated expression have none, so R matches nothing there.
2181
+ const notEquals = attr.comparison.operator === ECLTokenType.NOT_EQUALS;
2182
+ const reverse = !!attr.reverse;
1915
2183
  const matched = new Set();
1916
2184
  for (const rel of rels) {
1917
2185
  if (opContext) opContext.deadCheck('ecl:countAttributeMatches');
1918
- if (!rel.active) continue;
2186
+ if (!rel.active || rel.concrete) continue;
2187
+ if (!!rel.inbound !== reverse) continue;
1919
2188
  if (!types.any && !types.types.has(rel.relType)) continue;
1920
2189
  if (groupFilter !== null && rel.group !== groupFilter) continue;
1921
- if (valueSet.has(rel.target)) matched.add(rel.group + ':' + rel.target);
2190
+ const value = reverse ? rel.source : rel.target;
2191
+ if (valueSet.has(value) !== notEquals) matched.add(rel.group + ':' + value);
1922
2192
  }
1923
2193
  return matched.size;
1924
2194
  };
@@ -1983,12 +2253,58 @@ class SnomedServices {
1983
2253
  // target, group} — the shape the refinement matchers consume. Sharing this
1984
2254
  // shape lets the same matchers run against a post-coordinated expression's
1985
2255
  // relationships (see _expressionRelRecords).
2256
+ /**
2257
+ * A predicate over a stored concrete value for one comparison node:
2258
+ * numeric: = != < <= > >= against a number (integers and decimals compare as
2259
+ * numbers, so #5 = #5.0)
2260
+ * string: = / != against typed search terms (match / wild semantics, as for
2261
+ * description terms), any of a set
2262
+ * boolean: = / != true/false
2263
+ * A value of another kind never matches (and never "not-matches" either: a
2264
+ * string attribute is not "!= #5").
2265
+ */
2266
+ _eclConcreteMatcher = function (comparison) {
2267
+ switch (comparison.type) {
2268
+ case ECLNodeType.NUMERIC_COMPARISON: {
2269
+ const n = Number(comparison.value);
2270
+ const op = { [ECLTokenType.EQUALS]: '=', [ECLTokenType.NOT_EQUALS]: '!=', [ECLTokenType.LT]: '<', [ECLTokenType.LTE]: '<=', [ECLTokenType.GT]: '>', [ECLTokenType.GTE]: '>=' }[comparison.operator] || comparison.operator;
2271
+ return (v) => v.kind === 'number' && this._eclCompareValues(v.value, op, [n], (a, b) => a - b);
2272
+ }
2273
+ case ECLNodeType.STRING_COMPARISON: {
2274
+ const matchers = comparison.values.map(t => this._eclTermMatcher(typeof t === 'string' ? { kind: 'match', value: t } : t));
2275
+ const neg = comparison.operator === ECLTokenType.NOT_EQUALS;
2276
+ return (v) => v.kind === 'string' && (matchers.some(m => m(v.value)) !== neg);
2277
+ }
2278
+ case ECLNodeType.BOOLEAN_COMPARISON: {
2279
+ const neg = comparison.operator === ECLTokenType.NOT_EQUALS;
2280
+ return (v) => v.kind === 'boolean' && ((v.value === comparison.value) !== neg);
2281
+ }
2282
+ default:
2283
+ throw new Error(`Unsupported comparison ${comparison.type}`);
2284
+ }
2285
+ };
2286
+
1986
2287
  _conceptRelRecords = function (conceptIdx) {
1987
- const relIdxs = this.getConceptRelationships(conceptIdx);
1988
2288
  const out = [];
1989
- for (const relIdx of relIdxs) {
2289
+ for (const relIdx of this.getConceptRelationships(conceptIdx)) {
1990
2290
  out.push(this.relationships.getRelationship(relIdx));
1991
2291
  }
2292
+ // concrete value rows, for numeric / string / boolean comparisons
2293
+ if (this.concreteValues) {
2294
+ for (const cv of this.concreteValues.get(conceptIdx) || []) {
2295
+ out.push({ active: true, relType: cv.relType, group: cv.group, target: -1, concrete: cv.concrete });
2296
+ }
2297
+ }
2298
+ // inbound relationships, for reversed (R) attributes
2299
+ const concept = this.concepts.getConcept(conceptIdx);
2300
+ if (concept.inbounds) {
2301
+ for (const relIdx of this.refs.getReferences(concept.inbounds) || []) {
2302
+ const rel = this.relationships.getRelationship(relIdx);
2303
+ if (rel.active && rel.relType !== this.isAIndex) {
2304
+ out.push({ ...rel, inbound: true });
2305
+ }
2306
+ }
2307
+ }
1992
2308
  return out;
1993
2309
  };
1994
2310
 
@@ -2217,16 +2533,20 @@ class SnomedServices {
2217
2533
  * matches for a typical multi-word filter, with the event loop blocked for
2218
2534
  * the whole scan (tx.fhir.org incident 2026-09-17).
2219
2535
  *
2536
+ * Only descriptions in `lang` take part, when it is given (a text filter searches one
2537
+ * language: see SnomedProvider.searchFilter).
2538
+ *
2220
2539
  * @param {SearchFilterText} searchText
2221
2540
  * @param {boolean} includeInactive - include inactive concepts
2222
2541
  * @param {OperationContext|null} opContext - for yielding / deadline checks
2542
+ * @param {number|null} lang - the description language byte, or null for all
2223
2543
  * @returns {Promise<SnomedFilterContext>}
2224
2544
  */
2225
- async searchFilter(searchText, includeInactive = false, opContext = null) {
2545
+ async searchFilter(searchText, includeInactive = false, opContext = null, lang = null) {
2226
2546
  const filterText = (searchText && searchText.filter ? searchText.filter : '').toLowerCase().trim();
2227
2547
  const tokens = filterTokens(filterText);
2228
2548
  if (tokens.length > 0) {
2229
- const index = await this.getTextIndex(opContext);
2549
+ const index = await this.getTextIndex(opContext, lang);
2230
2550
  if (index) {
2231
2551
  const result = new SnomedFilterContext();
2232
2552
  const ordinals = index.search(tokens, opContext);
@@ -2237,7 +2557,16 @@ class SnomedServices {
2237
2557
  return result;
2238
2558
  }
2239
2559
  }
2240
- return await this.scanSearch(filterText, includeInactive, opContext);
2560
+ return await this.scanSearch(filterText, includeInactive, opContext, lang);
2561
+ }
2562
+
2563
+ // whether lang is the only language the edition has active descriptions in (then there is
2564
+ // nothing to restrict - the International Edition is all English)
2565
+ #onlyLanguage(lang) {
2566
+ if (!this._activeDescLanguages) {
2567
+ this._activeDescLanguages = this.descriptions.activeLanguages();
2568
+ }
2569
+ return this._activeDescLanguages.size === 1 && this._activeDescLanguages.has(lang);
2241
2570
  }
2242
2571
 
2243
2572
  /**
@@ -2248,28 +2577,53 @@ class SnomedServices {
2248
2577
  * @param {OperationContext|null} opContext
2249
2578
  * @returns {Promise<SnomedTextIndex|null>}
2250
2579
  */
2251
- async getTextIndex(opContext = null) {
2252
- if (this._textIndex) {
2253
- return this._textIndex;
2254
- }
2255
- if (!this._textIndexPromise) {
2256
- const started = Date.now();
2257
- // Logger fetched lazily: importers and tests require this module too
2258
- const textIndexLog = Logger.getInstance().child({module: 'tx-sct'});
2259
- this._textIndexPromise = SnomedTextIndex.build(this).then(index => {
2260
- this._textIndex = index;
2261
- textIndexLog.info(`SNOMED text index for ${this.versionUri}: ${index.words.length} words, built in ${Date.now() - started}ms`);
2262
- return index;
2263
- }, error => {
2264
- this._textIndexPromise = null;
2265
- textIndexLog.error(`SNOMED text index build failed for ${this.versionUri}: ${error.message}`);
2266
- return null;
2267
- });
2268
- }
2580
+ async getTextIndex(opContext = null, lang = null) {
2581
+ // One index per language the edition is searched in; an edition with only one language
2582
+ // (the International Edition) has just the one, for all its descriptions
2583
+ if (lang === null || this.#onlyLanguage(lang)) {
2584
+ if (this._textIndex) {
2585
+ return this._textIndex;
2586
+ }
2587
+ if (!this._textIndexPromise) {
2588
+ this._textIndexPromise = this.#buildTextIndex(null, index => { this._textIndex = index; }, () => { this._textIndexPromise = null; });
2589
+ }
2590
+ return await this.#awaitTextIndex(this._textIndexPromise, opContext);
2591
+ }
2592
+ if (!this._textIndexes) {
2593
+ this._textIndexes = new Map();
2594
+ this._textIndexPromises = new Map();
2595
+ }
2596
+ if (this._textIndexes.has(lang)) {
2597
+ return this._textIndexes.get(lang);
2598
+ }
2599
+ if (!this._textIndexPromises.has(lang)) {
2600
+ this._textIndexPromises.set(lang, this.#buildTextIndex(lang, index => { this._textIndexes.set(lang, index); },
2601
+ () => { this._textIndexPromises.delete(lang); }));
2602
+ }
2603
+ return await this.#awaitTextIndex(this._textIndexPromises.get(lang), opContext);
2604
+ }
2605
+
2606
+ #buildTextIndex(lang, onBuilt, onFailed) {
2607
+ const started = Date.now();
2608
+ // Logger fetched lazily: importers and tests require this module too
2609
+ const textIndexLog = Logger.getInstance().child({module: 'tx-sct'});
2610
+ const which = lang === null ? '' : ` (language ${lang})`;
2611
+ return SnomedTextIndex.build(this, lang).then(index => {
2612
+ onBuilt(index);
2613
+ textIndexLog.info(`SNOMED text index for ${this.versionUri}${which}: ${index.words.length} words, built in ${Date.now() - started}ms`);
2614
+ return index;
2615
+ }, error => {
2616
+ onFailed();
2617
+ textIndexLog.error(`SNOMED text index build failed for ${this.versionUri}${which}: ${error.message}`);
2618
+ return null;
2619
+ });
2620
+ }
2621
+
2622
+ async #awaitTextIndex(promise, opContext) {
2269
2623
  if (opContext && typeof opContext.waitFor === 'function') {
2270
- return await opContext.waitFor(this._textIndexPromise, 'sct:textIndex');
2624
+ return await opContext.waitFor(promise, 'sct:textIndex');
2271
2625
  }
2272
- return await this._textIndexPromise;
2626
+ return await promise;
2273
2627
  }
2274
2628
 
2275
2629
  /**
@@ -2283,7 +2637,7 @@ class SnomedServices {
2283
2637
  * @param {OperationContext|null} opContext
2284
2638
  * @returns {Promise<SnomedFilterContext>}
2285
2639
  */
2286
- async scanSearch(filterText, includeInactive = false, opContext = null) {
2640
+ async scanSearch(filterText, includeInactive = false, opContext = null, lang = null) {
2287
2641
  const result = new SnomedFilterContext();
2288
2642
  const searchTerms = filterText.split(/\s+/).filter(t => t.length > 0);
2289
2643
  const matches = [];
@@ -2315,7 +2669,7 @@ class SnomedServices {
2315
2669
 
2316
2670
  for (const descIndex of descriptionIndices) {
2317
2671
  const description = this.descriptions.getDescription(descIndex);
2318
- if (description.active) {
2672
+ if (description.active && (lang === null || description.lang === lang)) {
2319
2673
  const term = this.strings.getEntry(description.iDesc).toLowerCase();
2320
2674
  if (searchTerms.every(searchTerm => term.includes(searchTerm))) {
2321
2675
  matchFound = true;
@@ -2469,6 +2823,10 @@ class SnomedProvider extends BaseCSServices {
2469
2823
  if (disp) return disp;
2470
2824
 
2471
2825
  if (ctxt.isComplex()) {
2826
+ // an extension-codes supplement in scope can give a post-coordinated expression a display;
2827
+ // otherwise it is generated from the expression
2828
+ const ext = this.#extensionCodeDisplay(ctxt);
2829
+ if (ext) return ext;
2472
2830
  return this.sct.expressionServices.renderExpression(ctxt.expression, SnomedServicesRenderOption.FillMissing);
2473
2831
  } else {
2474
2832
  return this.sct.getDisplayName(ctxt.getReference(), this.sct.defaultLanguage);
@@ -2517,8 +2875,11 @@ class SnomedProvider extends BaseCSServices {
2517
2875
 
2518
2876
 
2519
2877
  if (ctxt.isComplex()) {
2520
- // For complex expressions, just add the display
2521
- const display = await this.display(context);
2878
+ // For complex expressions: the display an extension-codes supplement in scope gives it (first,
2879
+ // so it is the preferred display), and the display generated from the expression, which stays
2880
+ // a valid display
2881
+ this._listSupplementDesignations(this.#extensionCodeKey(ctxt), displays, true);
2882
+ const display = this.sct.expressionServices.renderExpression(ctxt.expression, SnomedServicesRenderOption.FillMissing);
2522
2883
  if (display) {
2523
2884
  displays.addDesignation(true, 'active', 'en-US', DesignationUse.PREFERRED, display);
2524
2885
  }
@@ -2635,6 +2996,30 @@ class SnomedProvider extends BaseCSServices {
2635
2996
  }
2636
2997
  }
2637
2998
 
2999
+ /**
3000
+ * The code an extension-codes supplement gives a post-coordinated expression under: the expression
3001
+ * as it was written, or, if no supplement in scope has that, the expression in its minimal rendering
3002
+ * (so that a supplement written without spaces or terms matches an expression written with them).
3003
+ *
3004
+ * @param {SnomedExpressionContext} ctxt - a complex expression
3005
+ * @returns {string} the code to look for in the supplements
3006
+ */
3007
+ #extensionCodeKey(ctxt) {
3008
+ const source = ctxt.getCode();
3009
+ if (source && this._extensionCodeDisplay(source)) {
3010
+ return source;
3011
+ }
3012
+ return this.sct.expressionServices.renderExpression(ctxt.expression, SnomedServicesRenderOption.Minimal);
3013
+ }
3014
+
3015
+ /**
3016
+ * @param {SnomedExpressionContext} ctxt - a complex expression
3017
+ * @returns {string|null} the display an extension-codes supplement in scope gives it, if one does
3018
+ */
3019
+ #extensionCodeDisplay(ctxt) {
3020
+ return this._extensionCodeDisplay(this.#extensionCodeKey(ctxt));
3021
+ }
3022
+
2638
3023
  getLanguageCode(langIndex) {
2639
3024
  return SCT_LANGUAGE_CODES[langIndex] || 'en';
2640
3025
  }
@@ -2756,15 +3141,50 @@ class SnomedProvider extends BaseCSServices {
2756
3141
  return SnomedExpressionContext.fromReference(key);
2757
3142
  }
2758
3143
 
3144
+ // The FHIR value for a concrete value in $lookup. A number is typed by the attribute's MRCM
3145
+ // range where it has one - so #4000 for a dec() attribute is a decimal, as the concept model
3146
+ // defines it - and otherwise by how it is written (#4000 an integer, #2.5 a decimal).
3147
+ #concreteLookupValue(cv) {
3148
+ const c = cv.concrete;
3149
+ switch (c.kind) {
3150
+ case 'string':
3151
+ return { valueString: c.value };
3152
+ case 'boolean':
3153
+ return { valueBoolean: c.value };
3154
+ case 'number': {
3155
+ let type = c.decimal ? 'dec' : 'int';
3156
+ const services = this.sct.expressionServices;
3157
+ const constraint = services && services.mrcmAttributeRanges ? services.mrcmAttributeRanges().get(cv.relType) : null;
3158
+ const range = constraint ? services.parseConcreteRange(constraint) : null;
3159
+ if (range && (range.type === 'dec' || range.type === 'int')) {
3160
+ type = range.type;
3161
+ }
3162
+ return type === 'int' && Number.isInteger(c.value) ? { valueInteger: c.value } : { valueDecimal: c.value };
3163
+ }
3164
+ default:
3165
+ return null;
3166
+ }
3167
+ }
3168
+
3169
+ /**
3170
+ * The concept's properties for $expand: built by extendLookup, so that an expansion reports
3171
+ * the same properties $lookup does (see CodeSystemProvider._propertiesFromLookup)
3172
+ */
3173
+ async properties(context, names) {
3174
+ return this._propertiesFromLookup(context, names);
3175
+ }
3176
+
2759
3177
  async extendLookup(context, props, params) {
2760
3178
  const ctxt = await this.#ensureContext(context);
2761
3179
  if (ctxt) {
2762
3180
  if (!(ctxt instanceof SnomedExpressionContext) || ctxt.expression?.concepts.length == 1) {
2763
- // Only what was asked for, if anything was (see _hasProp): effectiveTime, module,
3181
+ // Only what was asked for, if anything was (see _hasProp): effectiveTime, moduleId,
2764
3182
  // parent, child and the defining relationships are returned by default but are not
2765
3183
  // important enough to return when the client named the properties it wants.
2766
3184
  // The mappings (alternate identifiers such as the LOINC code) are returned by
2767
- // default too, and when asked for by name.
3185
+ // default too, and when asked for by name. property=* asks for every property the
3186
+ // SNOMED CT on FHIR page defines (adding sufficientlyDefined and semanticTag) except
3187
+ // the normal forms, which are computed and have to be asked for by name.
2768
3188
  if (this._hasProp(props, 'effectiveTime', true)) {
2769
3189
  const time = this.sct.concepts.getConcept(ctxt.getReference()).effectiveTime;
2770
3190
  // Pascal TDateTime epoch (1899-12-30) computed in UTC so the formatted
@@ -2794,11 +3214,35 @@ class SnomedProvider extends BaseCSServices {
2794
3214
  }
2795
3215
  }
2796
3216
 
2797
- if (this._hasProp(props, 'module', true)) {
3217
+ // moduleId, as the SNOMED CT on FHIR page names it ("module" is still understood)
3218
+ if (this._hasProp(props, 'moduleId', true) || SnomedProvider.#askedFor(props, 'module')) {
2798
3219
  const moduleId = this.sct.concepts.getModuleId(ctxt.getReference());
2799
3220
  if (moduleId) {
2800
3221
  const code = this.sct.getConceptId(moduleId);
2801
- this._addCodeProperty(params, 'property', 'module', code, null, this.sct.getDisplayName(moduleId));
3222
+ this._addCodeProperty(params, 'property', 'moduleId', code, null, this.sct.getDisplayName(moduleId));
3223
+ }
3224
+ }
3225
+
3226
+ // returned for property=* or by name, not by default
3227
+ if (this._hasProp(props, 'sufficientlyDefined', false)) {
3228
+ params.push({
3229
+ name: 'property',
3230
+ part: [
3231
+ {name: 'code', valueCode: 'sufficientlyDefined'},
3232
+ {name: 'value', valueBoolean: !this.sct.isPrimitive(ctxt.getReference())}
3233
+ ]
3234
+ });
3235
+ }
3236
+ if (this._hasProp(props, 'semanticTag', false)) {
3237
+ const tag = this.#semanticTag(ctxt.getReference());
3238
+ if (tag) {
3239
+ params.push({
3240
+ name: 'property',
3241
+ part: [
3242
+ {name: 'code', valueCode: 'semanticTag'},
3243
+ {name: 'value', valueString: tag}
3244
+ ]
3245
+ });
2802
3246
  }
2803
3247
  }
2804
3248
 
@@ -2819,8 +3263,28 @@ class SnomedProvider extends BaseCSServices {
2819
3263
  }
2820
3264
  }
2821
3265
 
3266
+ // concrete values (strengths, counts, ...): not relationships to concepts, so held apart
3267
+ for (const cv of (this.sct.concreteValues && this.sct.concreteValues.get(ctxt.getReference())) || []) {
3268
+ const relType = this.sct.getConceptId(cv.relType);
3269
+ if (this._hasProp(props, relType, true)) {
3270
+ const value = this.#concreteLookupValue(cv);
3271
+ if (value) {
3272
+ params.push({
3273
+ name: 'property',
3274
+ part: [
3275
+ {name: 'code', valueCode: relType},
3276
+ {name: 'value', ...value},
3277
+ {name: 'code-display', valueString: this.sct.getDisplayName(cv.relType)}
3278
+ ]
3279
+ });
3280
+ }
3281
+ }
3282
+ }
3283
+
2822
3284
  this.#addMappingProperties(ctxt, props, params);
2823
3285
  }
3286
+ // the normal forms: only when asked for by name (not by default, not by property=*)
3287
+ this.#addNormalFormProperties(ctxt, props, params);
2824
3288
  if (ctxt instanceof SnomedExpressionContext) {
2825
3289
  // ignore concepts for now, but list refinements and refinement groups
2826
3290
  for (const refinement of ctxt.expression.refinements) {
@@ -2835,6 +3299,63 @@ class SnomedProvider extends BaseCSServices {
2835
3299
  }
2836
3300
  }
2837
3301
 
3302
+ // Whether props names this property explicitly (property=* does not count)
3303
+ static #askedFor(props, name) {
3304
+ const lower = name.toLowerCase();
3305
+ return !!props && props.some(p => p && p.toLowerCase() === lower);
3306
+ }
3307
+
3308
+ // The semantic tag: the bracketed suffix of the concept's active fully specified name
3309
+ #semanticTag(reference) {
3310
+ const K = this.sct._displayConstants();
3311
+ const concept = this.sct.concepts.getConcept(reference);
3312
+ const descIndices = concept.descriptions ? (this.sct.refs.getReferences(concept.descriptions) || []) : [];
3313
+ for (const descIndex of descIndices) {
3314
+ const d = this.sct.descriptions.getDescription(descIndex);
3315
+ if (d.active && d.kind === K.fsn) {
3316
+ const m = /\(([^()]+)\)\s*$/.exec(this.sct.strings.getEntry(d.iDesc));
3317
+ if (m) {
3318
+ return m[1];
3319
+ }
3320
+ }
3321
+ }
3322
+ return null;
3323
+ }
3324
+
3325
+ // normalForm (with terms) and normalFormTerse (without), for a concept or an expression
3326
+ #addNormalFormProperties(ctxt, props, params) {
3327
+ const wantFull = SnomedProvider.#askedFor(props, 'normalForm');
3328
+ const wantTerse = SnomedProvider.#askedFor(props, 'normalFormTerse');
3329
+ if (!wantFull && !wantTerse) {
3330
+ return;
3331
+ }
3332
+ const services = this.sct.expressionServices;
3333
+ let nf;
3334
+ if (ctxt instanceof SnomedExpressionContext && ctxt.isComplex()) {
3335
+ nf = services.normaliseExpression(ctxt.expression);
3336
+ } else {
3337
+ nf = services.createNormalForm(ctxt.getReference());
3338
+ }
3339
+ if (wantFull) {
3340
+ params.push({
3341
+ name: 'property',
3342
+ part: [
3343
+ {name: 'code', valueCode: 'normalForm'},
3344
+ {name: 'value', valueString: services.renderExpression(nf, SnomedServicesRenderOption.FillMissing)}
3345
+ ]
3346
+ });
3347
+ }
3348
+ if (wantTerse) {
3349
+ params.push({
3350
+ name: 'property',
3351
+ part: [
3352
+ {name: 'code', valueCode: 'normalFormTerse'},
3353
+ {name: 'value', valueString: services.renderExpression(nf, SnomedServicesRenderOption.Minimal)}
3354
+ ]
3355
+ });
3356
+ }
3357
+ }
3358
+
2838
3359
  /**
2839
3360
  * The concept's alternate identifiers in the schemes we know (IDENTIFIER_SCHEMES), as
2840
3361
  * Coding-valued properties named for the scheme - e.g. loinc = http://loinc.org#2160-0.
@@ -2878,15 +3399,16 @@ class SnomedProvider extends BaseCSServices {
2878
3399
 
2879
3400
  // Filter support
2880
3401
  async doesFilter(prop, op, value) {
3402
+ prop = SnomedProvider.#filterProperty(prop);
2881
3403
  if (prop === 'concept') {
2882
3404
  const id = this.sct.stringToIdOrZero(value);
2883
- if (id !== 0n && ['=', 'is-a', 'descendent-of', 'in', 'generalizes', 'child-of'].includes(op)) {
3405
+ if (id !== 0n && ['=', 'is-a', 'descendent-of', 'in', 'generalizes', 'child-of', 'descendent-leaf', 'is-not-a'].includes(op)) {
2884
3406
  return this.sct.conceptExists(value);
2885
3407
  }
2886
3408
  if (op === 'in' && value.includes(',')) {
2887
3409
  let ok = true;
2888
3410
  for (const idStr of value.split(',')) {
2889
- const id = this.sct.stringToIdOrZero(idStr);
3411
+ const id = this.sct.stringToIdOrZero(idStr.trim());
2890
3412
  if (id === 0n) {
2891
3413
  ok = false;
2892
3414
  break;
@@ -2899,6 +3421,15 @@ class SnomedProvider extends BaseCSServices {
2899
3421
  return op === '=' && ['true', 'false'].includes(value);
2900
3422
  }
2901
3423
 
3424
+ if (prop === 'display' && op === 'regex') {
3425
+ try {
3426
+ SnomedProvider.#displayRegex(value);
3427
+ return true;
3428
+ } catch (e) {
3429
+ return false;
3430
+ }
3431
+ }
3432
+
2902
3433
  if (prop === 'moduleId') {
2903
3434
  const id = this.sct.stringToIdOrZero(value);
2904
3435
  return id !== 0n && op === '=';
@@ -2926,9 +3457,8 @@ class SnomedProvider extends BaseCSServices {
2926
3457
  }
2927
3458
 
2928
3459
  // eslint-disable-next-line no-unused-vars
2929
- async getPrepContext(iterate) {
2930
-
2931
- return new SnomedPrep(); // Simple filter context
3460
+ async getPrepContext(iterate, languages = null) {
3461
+ return new SnomedPrep(languages);
2932
3462
  }
2933
3463
 
2934
3464
  // Cache key pinning resolved analysis to the SNOMED edition url + version.
@@ -2980,7 +3510,84 @@ class SnomedProvider extends BaseCSServices {
2980
3510
  });
2981
3511
  }
2982
3512
 
3513
+ // Value sets in production say "code" where SNOMED defines "concept" (users don't tell
3514
+ // them apart), so the two are the same filter property here, as they are for code
3515
+ // systems defined as resources (cs-cs.js).
3516
+ static #filterProperty(prop) {
3517
+ return prop === 'code' ? 'concept' : prop;
3518
+ }
3519
+
3520
+ // display regex: matches a concept with an active description (FSN or synonym - a text
3521
+ // definition is not a display), in one language, whose whole term matches the pattern
3522
+ // (anchored, as for regex filters on code systems defined as resources), case-sensitively. RE2, so no catastrophic backtracking. The language is
3523
+ // the first one the request named that the edition has descriptions in, else the
3524
+ // edition's default.
3525
+ static #displayRegex(pattern) {
3526
+ return regexUtilities.compile('^(?:' + pattern + ')$');
3527
+ }
3528
+
3529
+ #filterLanguage(languages) {
3530
+ if (!this.sct._activeDescLanguages) {
3531
+ this.sct._activeDescLanguages = this.sct.descriptions.activeLanguages();
3532
+ }
3533
+ const present = this.sct._activeDescLanguages;
3534
+ for (const lang of languages || []) {
3535
+ const code = SCT_LANGUAGE_BYTES[(lang.language || '').toLowerCase()];
3536
+ if (code !== undefined && present.has(code)) {
3537
+ return code;
3538
+ }
3539
+ }
3540
+ return this.sct.defaultLanguage || 1;
3541
+ }
3542
+
3543
+ // the concept index of 900000000000550004 |Definition|, or -1
3544
+ #definitionKind() {
3545
+ if (this.sct._definitionKind === undefined) {
3546
+ const r = this.sct.concepts.findConcept(900000000000550004n);
3547
+ this.sct._definitionKind = r.found ? r.index : -1;
3548
+ }
3549
+ return this.sct._definitionKind;
3550
+ }
3551
+
3552
+ #displayMatches(set, reference) {
3553
+ const definition = this.#definitionKind();
3554
+ const concept = this.sct.concepts.getConcept(reference);
3555
+ const descIndices = concept.descriptions ? (this.sct.refs.getReferences(concept.descriptions) || []) : [];
3556
+ for (const descIndex of descIndices) {
3557
+ const d = this.sct.descriptions.getDescription(descIndex);
3558
+ if (d.active && d.lang === set.displayLang && d.kind !== definition && set.displayRegex.test(this.sct.strings.getEntry(d.iDesc))) {
3559
+ return true;
3560
+ }
3561
+ }
3562
+ return false;
3563
+ }
3564
+
3565
+ // The scan for an iterated display regex: every active description in the language,
3566
+ // a chunk at a time, yielding between chunks so the server keeps serving meanwhile.
3567
+ async #populateDisplayRegex(set) {
3568
+ if (set.populationDone) {
3569
+ return;
3570
+ }
3571
+ const CHUNK = 10000;
3572
+ const found = new Set();
3573
+ const test = (term) => set.displayRegex.test(term);
3574
+ const definition = this.#definitionKind();
3575
+ let count = Infinity;
3576
+ for (let start = 0; start < count; start += CHUNK) {
3577
+ if (this.opContext) {
3578
+ await this.opContext.checkAndYield('snomed:displayRegex');
3579
+ }
3580
+ count = this.sct.descriptions.matchActiveTerms(start, start + CHUNK, set.displayLang, this.sct.strings, test, found, definition);
3581
+ }
3582
+ set.descendants = [...found].sort((a, b) => a - b);
3583
+ set.populationDone = true;
3584
+ if (set.displayCache) {
3585
+ set.displayCache.map.set(set.displayCache.key, set.descendants);
3586
+ }
3587
+ }
3588
+
2983
3589
  async filter(filterContext, forIteration, prop, op, value, fc) {
3590
+ prop = SnomedProvider.#filterProperty(prop);
2984
3591
 
2985
3592
  if (prop === 'concept') {
2986
3593
  const id = this.sct.stringToIdOrZero(value);
@@ -3009,6 +3616,14 @@ class SnomedProvider extends BaseCSServices {
3009
3616
  filterContext.filters.push(this.sct.filterGeneralizes(id, false));
3010
3617
  return null;
3011
3618
  }
3619
+ case 'descendent-leaf': {
3620
+ filterContext.filters.push(this.sct.filterDescendentLeaf(id));
3621
+ return null;
3622
+ }
3623
+ case 'is-not-a': {
3624
+ filterContext.filters.push(this.sct.filterIsNotA(id, this.opContext));
3625
+ return null;
3626
+ }
3012
3627
  case 'in': {
3013
3628
  filterContext.filters.push(this.sct.filterIn(value));
3014
3629
  return null;
@@ -3018,6 +3633,31 @@ class SnomedProvider extends BaseCSServices {
3018
3633
  }
3019
3634
  }
3020
3635
 
3636
+ if (prop === 'display' && op === 'regex') {
3637
+ const result = new SnomedFilterContext();
3638
+ result.displayRegex = SnomedProvider.#displayRegex(value);
3639
+ result.displayLang = this.#filterLanguage(filterContext.languages);
3640
+ // Like ECL, a cached value set's filter keeps what it resolved to: here, per edition
3641
+ // and language, the concepts the scan found, so the scan is paid once.
3642
+ if (fc) {
3643
+ let map = fc[SNOMED_DISPLAY_REGEX];
3644
+ if (!map) {
3645
+ map = new Map();
3646
+ Object.defineProperty(fc, SNOMED_DISPLAY_REGEX, { value: map, enumerable: false, configurable: true, writable: true });
3647
+ }
3648
+ const key = this._snomedFilterCacheKey() + '|' + result.displayLang;
3649
+ const cached = map.get(key);
3650
+ if (cached) {
3651
+ result.descendants = cached;
3652
+ result.populationDone = true;
3653
+ } else {
3654
+ result.displayCache = { map, key };
3655
+ }
3656
+ }
3657
+ filterContext.filters.push(result);
3658
+ return null;
3659
+ }
3660
+
3021
3661
  if (prop === 'inactive') {
3022
3662
  if (value !== 'true' && value !== 'false') {
3023
3663
  throw new Error(`Invalid filter value: ${value}`);
@@ -3130,6 +3770,9 @@ class SnomedProvider extends BaseCSServices {
3130
3770
  }
3131
3771
 
3132
3772
  async filterSize(filterContext, set) {
3773
+ if (set.displayRegex) {
3774
+ await this.#populateDisplayRegex(set);
3775
+ }
3133
3776
  // A wildcard (`*`) set is lazy - it carries the eclWildcard flag rather than a
3134
3777
  // materialised descendant list - so count the active concepts it denotes.
3135
3778
  if (set.eclWildcard && (!set.descendants || set.descendants.length === 0)) {
@@ -3148,6 +3791,9 @@ class SnomedProvider extends BaseCSServices {
3148
3791
 
3149
3792
  async filterMore(filterContext, set) {
3150
3793
  set.cursor = set.cursor || 0;
3794
+ if (set.displayRegex) {
3795
+ await this.#populateDisplayRegex(set);
3796
+ }
3151
3797
  this.#ensurePopulated(set);
3152
3798
  const size = await this.filterSize(filterContext, set);
3153
3799
  return set.cursor < size;
@@ -3206,6 +3852,8 @@ class SnomedProvider extends BaseCSServices {
3206
3852
  let concept = this.sct.concepts.getConcept(reference);
3207
3853
  let moduleId = this.sct.concepts.getModuleId(concept.index);
3208
3854
  found = moduleId === set.moduleId;
3855
+ } else if (set.displayRegex) {
3856
+ found = this.#displayMatches(set, reference);
3209
3857
  } else if (set.propProp || set.propValue) {
3210
3858
  found = false;
3211
3859
  const relationships = this.sct.getConceptRelationships(reference);
@@ -3255,6 +3903,10 @@ class SnomedProvider extends BaseCSServices {
3255
3903
  return this.sct.concepts.getModuleId(reference) === set.moduleId;
3256
3904
  }
3257
3905
 
3906
+ if (set.displayRegex) {
3907
+ return this.#displayMatches(set, reference);
3908
+ }
3909
+
3258
3910
  if (set.propProp || set.propValue) {
3259
3911
  const relationships = this.sct.getConceptRelationships(reference);
3260
3912
  for (let relationshipRef of relationships) {
@@ -3375,8 +4027,23 @@ class SnomedProvider extends BaseCSServices {
3375
4027
  // Search filter
3376
4028
  // eslint-disable-next-line no-unused-vars
3377
4029
  async searchFilter(filterContext, filter, sort) {
3378
- // `sort` is not a match-mode flag: results are always ranked.
3379
- let f = await this.sct.searchFilter(filter, false, this.opContext);
4030
+ // `sort` is not a match-mode flag: results are always ranked. Only descriptions in one
4031
+ // language take part, chosen as for the display filter: the first language the request
4032
+ // names that the edition has descriptions in, else the edition's default; and the codes
4033
+ // whose designations in that language, in a supplement in use, match
4034
+ const lang = this.#filterLanguage(filterContext.languages);
4035
+ let f = await this.sct.searchFilter(filter, false, this.opContext, lang);
4036
+ if (this.supplements && this.supplements.length > 0) {
4037
+ const have = new Set(f.matches.map(m => m.index));
4038
+ for (const code of this._supplementSearchCodes(filter, new Language(SCT_LANGUAGE_CODES[lang] || 'en'))) {
4039
+ const id = this.sct.stringToIdOrZero(code);
4040
+ const r = id !== 0n ? this.sct.concepts.findConcept(id) : null;
4041
+ if (r && r.found && !have.has(r.index) && this.sct.isActive(r.index)) {
4042
+ f.matches.push({ index: r.index, term: this.sct.concepts.getConceptId(r.index), priority: 10 });
4043
+ have.add(r.index);
4044
+ }
4045
+ }
4046
+ }
3380
4047
  filterContext.filters.push(f);
3381
4048
  return f;
3382
4049
  }
@@ -3741,9 +4408,12 @@ class SnomedProvider extends BaseCSServices {
3741
4408
  * Factory for creating SNOMED services and providers
3742
4409
  */
3743
4410
  class SnomedServicesFactory extends CodeSystemFactoryProvider {
3744
- constructor(i18n, filePath) {
4411
+ constructor(i18n, filePath, loadMode = 'read', log = null) {
3745
4412
  super(i18n);
3746
4413
  this.filePath = filePath;
4414
+ this.loadMode = loadMode; // 'read', 'mmap' or 'prewarm' - see SnomedFileReader
4415
+ this.log = log;
4416
+ this.mapped = false; // set by load(): whether the cache file really is memory-mapped
3747
4417
  this.uses = 0;
3748
4418
  this._loaded = false;
3749
4419
  this._sharedData = null;
@@ -3757,6 +4427,26 @@ class SnomedServicesFactory extends CodeSystemFactoryProvider {
3757
4427
  return this._sharedData.versionUri;
3758
4428
  }
3759
4429
 
4430
+ /**
4431
+ * A concept links to snomed.info, which redirects to a browser: in its edition when that is
4432
+ * one snomed.info knows, otherwise without an edition. An expression links to the browser's
4433
+ * home page. The same links as SnomedUtilities.getSctLink in org.hl7.fhir.core.
4434
+ */
4435
+ codeLink(context) {
4436
+ if (!context || !context.expression) {
4437
+ return undefined;
4438
+ }
4439
+ const code = context.isSimple() ? (context.source || context.expression.concepts[0].code) : null;
4440
+ if (!code || !/^\d+$/.test(code)) {
4441
+ return 'https://browser.ihtsdotools.org/';
4442
+ }
4443
+ const m = /^http:\/\/snomed\.info\/sct\/(\d+)/.exec(this.version() || '');
4444
+ if (m && SNOMED_LINKABLE_EDITIONS.has(m[1])) {
4445
+ return `http://snomed.info/sct/${m[1]}/id/${code}`;
4446
+ }
4447
+ return `http://snomed.info/id/${code}`;
4448
+ }
4449
+
3760
4450
  getPartialVersion() {
3761
4451
  let ver = this.version();
3762
4452
  if (ver.includes("/version")) {
@@ -3775,39 +4465,86 @@ class SnomedServicesFactory extends CodeSystemFactoryProvider {
3775
4465
  * http://snomed.info/sct?fhir_vs=refset – list of reference sets
3776
4466
  * http://snomed.info/sct?fhir_vs=refset/<id> – members of a reference set
3777
4467
  * http://snomed.info/sct?fhir_vs=isa/<id> – concept and descendants
4468
+ * http://snomed.info/sct?fhir_vs=ecl/<ecl> – the concepts an ECL expression selects
3778
4469
  *
3779
4470
  * The URL may optionally include edition and/or version segments:
3780
4471
  * http://snomed.info/sct/<edition>?fhir_vs...
3781
4472
  * http://snomed.info/sct/<edition>/version/<ver>?fhir_vs...
3782
4473
  *
4474
+ * Which edition and version the value set is drawn from is the provider's choice, made
4475
+ * with implicitValueSetForm(): the generic form from the default edition, the edition
4476
+ * form from the latest version of that edition, the version form from that version.
4477
+ * For the edition and version forms, the value set's include names this version, so that
4478
+ * it is expanded against it; the generic form leaves the version to the request.
4479
+ *
3783
4480
  * @param {string} url - The ValueSet URL to resolve
3784
4481
  * @returns {object|null} A FHIR ValueSet JSON object, or null if the URL is not recognised
3785
4482
  */
3786
4483
  async buildKnownValueSet(url, version) {
3787
- if (!url.startsWith("http://snomed.info/sct")) {
4484
+ if (!url.startsWith('http://snomed.info/')) {
3788
4485
  return null;
3789
4486
  }
4487
+ await this.#ensureLoaded();
3790
4488
  if (version != null && !this.version().startsWith(version)) {
3791
4489
  return null;
3792
4490
  }
4491
+ const form = this.implicitValueSetForm(url);
4492
+ if (!form) {
4493
+ return null;
4494
+ }
4495
+ const id = form.query;
4496
+ const vs = await this.#buildImplicitValueSet(url, id);
4497
+ if (vs && form.form !== 'generic') {
4498
+ for (const include of vs.compose.include) {
4499
+ include.version = this.version();
4500
+ }
4501
+ }
4502
+ return vs;
4503
+ }
3793
4504
 
3794
- const URI_SNOMED = 'http://snomed.info/sct';
3795
-
3796
- // Extract the query portion (?fhir_vs...) if this is a recognised SNOMED implicit VS URL
3797
- let id = null;
3798
- const qIdx = url.indexOf('?');
4505
+ /**
4506
+ * Whether url is an implicit value set of this edition, and in which form:
4507
+ * generic - http://snomed.info/sct?fhir_vs... (any edition)
4508
+ * edition - http://snomed.info/sct/<edition>?fhir_vs... (this edition, no version given)
4509
+ * version - http://snomed.info/sct/<edition>/version/<date>?fhir_vs... (this version)
4510
+ * The edition and version forms are also recognised under this version's own base
4511
+ * (http://snomed.info/xsct/... for the test edition).
4512
+ *
4513
+ * @param {string} url
4514
+ * @returns {{form: string, query: string}|null}
4515
+ */
4516
+ implicitValueSetForm(url) {
4517
+ if (!url || !this._sharedData) {
4518
+ return null;
4519
+ }
4520
+ const qIdx = url.indexOf('?fhir_vs');
3799
4521
  if (qIdx === -1) {
3800
4522
  return null;
3801
4523
  }
3802
-
3803
- if (url.startsWith('http://snomed.info/sct?fhir_vs') ||
3804
- url.startsWith(`http://snomed.info/sct/${this.edition}?fhir_vs`) ||
3805
- url.startsWith(`http://snomed.info/sct/${this.edition}/version/${this.version()}?fhir_vs`)) {
3806
- id = url.substring(qIdx);
3807
- } else {
4524
+ const base = url.substring(0, qIdx);
4525
+ const query = url.substring(qIdx);
4526
+ if (query !== '?fhir_vs' && !query.startsWith('?fhir_vs=')) {
3808
4527
  return null;
3809
4528
  }
4529
+ const versionUri = this.version();
4530
+ const edition = this._sharedData.edition;
4531
+ const date = this._sharedData.version;
4532
+ const editionBases = new Set([`http://snomed.info/sct/${edition}`, this.getPartialVersion()]);
4533
+ const versionBases = new Set([versionUri, `http://snomed.info/sct/${edition}/version/${date}`]);
4534
+ if (base === 'http://snomed.info/sct') {
4535
+ return { form: 'generic', query };
4536
+ }
4537
+ if (editionBases.has(base)) {
4538
+ return { form: 'edition', query };
4539
+ }
4540
+ if (versionBases.has(base)) {
4541
+ return { form: 'version', query };
4542
+ }
4543
+ return null;
4544
+ }
3810
4545
 
4546
+ async #buildImplicitValueSet(url, id) {
4547
+ const URI_SNOMED = 'http://snomed.info/sct';
3811
4548
  const now = new Date().toISOString();
3812
4549
 
3813
4550
  if (id === '?fhir_vs=refset') {
@@ -3821,7 +4558,6 @@ class SnomedServicesFactory extends CodeSystemFactoryProvider {
3821
4558
  resourceType: 'ValueSet',
3822
4559
  url,
3823
4560
  status: 'active',
3824
- version: this.versionDate,
3825
4561
  name: 'SNOMEDCTReferenceSetList',
3826
4562
  title: 'SNOMED CT Reference Set List',
3827
4563
  description: 'Reference Sets defined in this SNOMED-CT version',
@@ -3841,7 +4577,6 @@ class SnomedServicesFactory extends CodeSystemFactoryProvider {
3841
4577
  resourceType: 'ValueSet',
3842
4578
  url,
3843
4579
  status: 'active',
3844
- version: this.versionDate,
3845
4580
  name: 'ALLSNOMEDCT',
3846
4581
  title: 'SNOMED CT Reference Set (All of SNOMED CT)',
3847
4582
  description: 'SNOMED CT Reference Set (All of SNOMED CT)',
@@ -3868,7 +4603,6 @@ class SnomedServicesFactory extends CodeSystemFactoryProvider {
3868
4603
  resourceType: 'ValueSet',
3869
4604
  url,
3870
4605
  status: 'active',
3871
- version: this.versionDate,
3872
4606
  name: 'SNOMEDCTRefSet' + refsetId,
3873
4607
  title: 'SNOMED CT Reference Set ' + refsetId,
3874
4608
  description: this.snomedServices.getDisplayName(ref.index),
@@ -3886,6 +4620,38 @@ class SnomedServicesFactory extends CodeSystemFactoryProvider {
3886
4620
  };
3887
4621
  }
3888
4622
 
4623
+ if (id.startsWith('?fhir_vs=ecl/')) {
4624
+ // the ECL is URL-encoded in the value set URL; accept it already decoded as well
4625
+ let ecl = id.substring(13);
4626
+ try {
4627
+ ecl = decodeURIComponent(ecl);
4628
+ } catch (e) {
4629
+ // not encoded (or not validly): use it as it stands
4630
+ }
4631
+ if (!ecl.trim()) {
4632
+ return null;
4633
+ }
4634
+ return {
4635
+ resourceType: 'ValueSet',
4636
+ url,
4637
+ status: 'active',
4638
+ name: 'SNOMEDCTECL',
4639
+ title: 'SNOMED CT ECL ' + ecl,
4640
+ description: 'The SNOMED CT concepts selected by the expression constraint ' + ecl,
4641
+ date: now,
4642
+ compose: {
4643
+ include: [{
4644
+ system: URI_SNOMED,
4645
+ filter: [{
4646
+ property: 'constraint',
4647
+ op: '=',
4648
+ value: ecl,
4649
+ }],
4650
+ }],
4651
+ },
4652
+ };
4653
+ }
4654
+
3889
4655
  if (id.startsWith('?fhir_vs=isa/')) {
3890
4656
  const conceptId = id.substring(13);
3891
4657
  let ref = this.snomedServices.concepts.findConcept(conceptId);
@@ -3896,7 +4662,6 @@ class SnomedServicesFactory extends CodeSystemFactoryProvider {
3896
4662
  resourceType: 'ValueSet',
3897
4663
  url,
3898
4664
  status: 'active',
3899
- version: this.versionDate,
3900
4665
  name: 'SNOMEDCTConcept' + conceptId,
3901
4666
  title: 'SNOMED CT Concept ' + conceptId + ' and descendants',
3902
4667
  description: 'All Snomed CT concepts for ' + this.snomedServices.getDisplayName(ref.index),
@@ -3923,8 +4688,9 @@ class SnomedServicesFactory extends CodeSystemFactoryProvider {
3923
4688
  }
3924
4689
 
3925
4690
  async load() {
3926
- const reader = new SnomedFileReader(this.filePath);
4691
+ const reader = new SnomedFileReader(this.filePath, this.loadMode, this.log);
3927
4692
  this._sharedData = await reader.loadSnomedData();
4693
+ this.mapped = reader.mapped;
3928
4694
  this.snomedServices = new SnomedServices(this._sharedData);
3929
4695
  this._loaded = true;
3930
4696
  }