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
@@ -35,14 +35,20 @@ that the server does not use. `filterECL` is the one that matters.
35
35
  reference set's own fields (concept, integer and string typed from the RF2
36
36
  file name). Since inactive member rows are dropped at import, `active = false`
37
37
  never matches.
38
- - `AND` / `OR` / `MINUS` (and `,`), with the ECL 6.4 rule that conjunction and
38
+ - Refinements: `=` and `!=` against an expression value (`!=` counts the
39
+ occurrences whose value is outside the set, so `A != X` needs at least one
40
+ A that is not X, and `[0..0] A != X` means every A is X); reversed
41
+ attributes (`R A = X`: the concept is the value of an A on something in X,
42
+ evaluated over the concept's inbound relationships); attribute names that
43
+ are a concept, `*`, or any sub expression (`<< 762705008 = ...`); attribute
44
+ sets joined by `AND`, `OR` or `,`, with brackets; attribute groups,
45
+ including `AND`/`OR` inside and between groups; attribute and group
46
+ cardinality, including `[0..0]`. Cardinality counts occurrences per role
47
+ group, per the SNOMED Computable Languages Group ruling (see
48
+ `_countAttributeMatches`).
49
+ - `AND` / `OR` / `MINUS`, and `,` as a conjunction at the expression level as
50
+ well as in refinements, with the ECL 6.4 rule that conjunction and
39
51
  disjunction cannot be mixed without brackets.
40
- - Refinements: `=` against an expression value; attribute names that are a
41
- concept, `*`, or any sub expression (`<< 762705008 = ...`); attribute sets
42
- joined by `AND`, `OR` or `,`, with brackets; attribute groups, including
43
- `AND`/`OR` inside and between groups; attribute and group cardinality,
44
- including `[0..0]`. Cardinality counts occurrences per role group, per the
45
- SNOMED Computable Languages Group ruling (see `_countAttributeMatches`).
46
52
  - Dotted expressions (`< X . 363698007`, `. << 363698007`, `. (A OR B)`).
47
53
  - `{{ C ... }}` concept filters on `active`, `definitionStatus`,
48
54
  `definitionStatusId`, `moduleId` and `effectiveTime`.
@@ -56,26 +62,37 @@ that the server does not use. `filterECL` is the one that matters.
56
62
  must be satisfied by a single description; separate blocks may be satisfied
57
63
  by different descriptions. Only active descriptions are considered unless
58
64
  the block has an `active` filter.
65
+ - Concrete values in refinements - numeric (`= #5`, `>= #1.5`, `!= #-1`),
66
+ string (`= "x"`, `= match:"x"`, a set) and boolean (`= true`) - against the
67
+ concrete value relationships, which the importer reads from
68
+ `sct2_RelationshipConcreteValues_*` into cache version 19. Numbers compare
69
+ as numbers (`#5` matches `#5.0`); strings use the same match / wild
70
+ semantics as description terms; a value of another kind never matches.
71
+ An edition imported before version 19 has no concrete values and reports
72
+ such a refinement as unsupported rather than answering with nothing.
73
+ - History supplements: `{{ + HISTORY }}` (= MAX), `HISTORY-MIN` (SAME AS),
74
+ `HISTORY-MOD` (SAME AS, WAS A, REPLACED BY, PARTIALLY EQUIVALENT TO),
75
+ `HISTORY-MAX` (every reference set under 900000000000522004 |Historical
76
+ association|), `HISTORY (*)` and `HISTORY (expression)`: the inactive
77
+ concepts that the selected association reference sets associate with a
78
+ concept in the result are added to it. A reference set the edition does not
79
+ carry contributes nothing. Whether a bare `HISTORY` means MAX is taken from
80
+ Snow Owl's reading of the specification - to be confirmed.
81
+ - Alternate identifiers (`LOINC#14394-1`, `"LOINC#14394-1"`): the scheme
82
+ alias names an identifier scheme (`IDENTIFIER_SCHEMES` in `cs-snomed.js`:
83
+ LOINC = 30051010000102, from the LOINC Extension's `sct2_Identifier` file,
84
+ cache version 18+), the code resolves to the concept(s) carrying it, and
85
+ hierarchy operators apply to them as to any set. An unknown scheme or a
86
+ code nobody carries is `INVALID_ECL`.
59
87
  - Comments (`/* ... */`), terms (`|...|`, not checked against descriptions).
60
88
 
61
89
  ### Parsed but reported as unsupported
62
90
 
63
- - Reverse attributes (`R 363698007 = ...`).
64
- - `!=` against an expression value in a refinement.
65
- - Concrete values in refinements - numeric (`= #5`, `>= #1.5`), string
66
- (`= "x"`, `= match:"x"`), boolean (`= true`). The importer does not read
67
- `sct2_RelationshipConcreteValues_*`, so no concrete values are in the cache
68
- to compare against. Supporting them means extending the importer and the
69
- relationship record, and rebuilding every cache.
70
- - Alternate identifiers (`"ICD10#A01.1"`, `SCTID#123`). No alternate
71
- identifier reference sets are loaded.
72
91
  - Reference set field selection on member-of (`^ [targetComponentId] refset`,
73
92
  `^ [referencedComponentId, targetComponentId] refset`). Deliberate: the
74
93
  selected fields, and the referenced components of many reference sets,
75
94
  are descriptions or other non-concept components, which an expansion has
76
95
  no way to represent.
77
- - History supplements (`{{ + HISTORY-MIN / MOD / MAX }}`). The association
78
- reference sets they draw on are loaded, so this is implementable.
79
96
 
80
97
  ### Not parsed
81
98
 
@@ -99,7 +116,13 @@ that the server does not use. `filterECL` is the one that matters.
99
116
  - `^` on a concept that is not a reference set yields an empty result rather
100
117
  than an error (Snowstorm's behaviour; agreed in the tx-ecosystem suite).
101
118
  `^` on a reference set whose members are descriptions (a language reference
102
- set) is reported as unsupported - there are no concepts to return.
119
+ set) is reported as unsupported when that reference set is named explicitly
120
+ - there are no concepts to return. Inside a computed operand (`^ *`,
121
+ `^ (< 900000000000455006)`) such a reference set contributes nothing and
122
+ the rest of the operand still counts.
123
+ - An attribute name without a comparison (`< X : 363698007`, or `(< X : A = v
124
+ OR B)` where the OR binds inside the refinement) is a syntax error, as the
125
+ grammar requires.
103
126
  - The ECL result for a value set filter is cached per (value set, code system
104
127
  version), so the first expansion of a large constraint is slow and later ones
105
128
  are not.
@@ -181,8 +204,9 @@ role group pairing.
181
204
 
182
205
  - Stated relationships and the OWL axiom reference sets. Everything is driven
183
206
  by the distributed inferred relationships; there is no classification.
184
- - Concrete value relationships (see ECL above).
185
- - Alternate identifier reference sets.
207
+ - Concrete value relationships are read into cache version 19 (October 2026);
208
+ caches built before that have none, and a concrete-value ECL refinement on
209
+ them is reported as unsupported.
186
210
  - Inactive reference set member rows (only active rows are stored).
187
211
  - Module ids and definition status on concepts are recorded by the current
188
212
  importer; caches built before mid-2026 have neither (every concept looks
package/tx/sct/ecl.js CHANGED
@@ -479,6 +479,22 @@ class ECLLexer {
479
479
  }
480
480
  }
481
481
 
482
+ /**
483
+ * Bounds on the shape of an ECL expression, so that a hostile expression is a
484
+ * clean parse error rather than a RangeError (stack overflow) here, in the
485
+ * evaluator, or in any of the other recursive AST walkers.
486
+ *
487
+ * MAX_ECL_DEPTH limits nesting - bracketed expression constraints, bracketed
488
+ * refinements and attribute sets. Real ECL nests a handful of levels.
489
+ *
490
+ * MAX_ECL_TERMS limits the number of sub expression constraints (focus
491
+ * concepts, refinement values, ...). It is needed as well as the depth limit
492
+ * because a compound chain ("A OR B OR C ...") is parsed iteratively but builds
493
+ * a left-deep tree, one level per operator, which every walker then recurses.
494
+ */
495
+ const MAX_ECL_DEPTH = 100;
496
+ const MAX_ECL_TERMS = 500;
497
+
482
498
  /**
483
499
  * ECL Parser - Parses tokens into AST
484
500
  */
@@ -487,6 +503,20 @@ class ECLParser {
487
503
  this.tokens = tokens;
488
504
  this.position = 0;
489
505
  this.current = this.tokens[0] || { type: ECLTokenType.EOF };
506
+ this.depth = 0;
507
+ this.terms = 0;
508
+ }
509
+
510
+ /** Run a parse step one nesting level deeper, enforcing MAX_ECL_DEPTH. */
511
+ #nested(fn) {
512
+ if (++this.depth > MAX_ECL_DEPTH) {
513
+ this.error(`The expression nests more than ${MAX_ECL_DEPTH} levels deep`);
514
+ }
515
+ try {
516
+ return fn();
517
+ } finally {
518
+ this.depth--;
519
+ }
490
520
  }
491
521
 
492
522
  error(message) {
@@ -526,7 +556,7 @@ class ECLParser {
526
556
  }
527
557
 
528
558
  parseExpressionConstraint() {
529
- return this.parseCompoundExpressionConstraint();
559
+ return this.#nested(() => this.parseCompoundExpressionConstraint());
530
560
  }
531
561
 
532
562
  parseCompoundExpressionConstraint() {
@@ -538,8 +568,11 @@ class ECLParser {
538
568
  // this compound sequence and reject a mix (e.g. "<< A OR << B AND << C").
539
569
  let compoundOp = null;
540
570
 
541
- while (this.match(ECLTokenType.AND, ECLTokenType.OR, ECLTokenType.MINUS)) {
542
- const operator = this.current;
571
+ // "," is a conjunction in the ECL grammar (conjunction = "AND" / ","), at
572
+ // this level as well as in refinements; a comma that reaches here was not
573
+ // claimed by a refinement.
574
+ while (this.match(ECLTokenType.AND, ECLTokenType.OR, ECLTokenType.MINUS, ECLTokenType.COMMA)) {
575
+ const operator = this.match(ECLTokenType.COMMA) ? { ...this.current, type: ECLTokenType.AND } : this.current;
543
576
  if (compoundOp !== null && operator.type !== compoundOp) {
544
577
  throw new Error(
545
578
  'Ambiguous ECL expression: conjunction, disjunction and exclusion ' +
@@ -604,6 +637,9 @@ class ECLParser {
604
637
  }
605
638
 
606
639
  parseSubExpressionConstraint() {
640
+ if (++this.terms > MAX_ECL_TERMS) {
641
+ this.error(`The expression has more than ${MAX_ECL_TERMS} terms`);
642
+ }
607
643
  // Handle constraint operators
608
644
  let operator = null;
609
645
  if (this.match(
@@ -698,7 +734,8 @@ class ECLParser {
698
734
  }
699
735
 
700
736
  /**
701
- * Parse one "{{ ... }}" block. Returns { kind: 'M'|'D'|'C'|'+', filters } where
737
+ * Parse one "{{ ... }}" block (the D of a description filter is optional). Returns
738
+ * { kind: 'M'|'D'|'C'|'+', filters } where
702
739
  * filters is only populated for member filters; the other kinds keep their
703
740
  * raw token text so the evaluator can report them as unsupported.
704
741
  */
@@ -711,8 +748,13 @@ class ECLParser {
711
748
  } else if (this.match(ECLTokenType.SCTID) && /^[MDCmdc]$/.test(this.current.value)) {
712
749
  kind = this.current.value.toUpperCase();
713
750
  this.advance();
751
+ } else if (this.match(ECLTokenType.SCTID) && /^[A-Za-z_]/.test(this.current.value)) {
752
+ // ECL 2.2: descriptionFilterConstraint = "{{" ws [ "d" / "D" ] ws descriptionFilter ...
753
+ // - the D is optional, so a filter that starts with a field name (term, language,
754
+ // type, ...) is a description filter
755
+ kind = 'D';
714
756
  } else {
715
- this.error('Expected M, D, C or + after {{');
757
+ this.error('Expected M, D, C, + or a description filter after {{');
716
758
  }
717
759
 
718
760
  if (kind === 'C' || kind === 'D') {
@@ -726,18 +768,30 @@ class ECLParser {
726
768
  return { type: ECLNodeType.FILTER_CONSTRAINT, kind, filters };
727
769
  }
728
770
 
729
- if (kind !== 'M') {
730
- // Skip to the closing }} keeping the raw text for diagnostics
731
- const raw = [];
732
- while (!this.match(ECLTokenType.RFILTER)) {
733
- if (this.match(ECLTokenType.EOF)) {
734
- this.error('Unterminated filter constraint - missing }}');
771
+ if (kind === '+') {
772
+ // historySupplement = "{{" "+" "HISTORY" [ "-MIN" / "-MOD" / "-MAX" / "(" (expressionConstraint / "*") ")" ] "}}"
773
+ if (!this.match(ECLTokenType.SCTID) || !/^HISTORY(-MIN|-MOD|-MAX)?$/i.test(this.current.value)) {
774
+ this.error('Expected HISTORY, HISTORY-MIN, HISTORY-MOD or HISTORY-MAX after {{ +');
775
+ }
776
+ const word = this.current.value.toUpperCase();
777
+ this.advance();
778
+ const node = { type: ECLNodeType.FILTER_CONSTRAINT, kind, profile: word === 'HISTORY' ? 'MAX' : word.substring(8) };
779
+ if (this.match(ECLTokenType.LPAREN)) {
780
+ if (node.profile !== 'MAX' || word !== 'HISTORY') {
781
+ this.error('A history profile (HISTORY-MIN/MOD/MAX) cannot also name reference sets');
735
782
  }
736
- raw.push(this.current.value);
737
783
  this.advance();
784
+ if (this.match(ECLTokenType.WILDCARD)) {
785
+ this.advance();
786
+ node.profile = 'MAX';
787
+ } else {
788
+ node.profile = 'EXPLICIT';
789
+ node.refsets = this.parseExpressionConstraint();
790
+ }
791
+ this.expect(ECLTokenType.RPAREN);
738
792
  }
739
- this.advance(); // }}
740
- return { type: ECLNodeType.FILTER_CONSTRAINT, kind, raw: raw.join(' ') };
793
+ this.expect(ECLTokenType.RFILTER);
794
+ return node;
741
795
  }
742
796
 
743
797
  const filters = [this.parseMemberFilter()];
@@ -1241,7 +1295,7 @@ class ECLParser {
1241
1295
  * returned as-is; otherwise an ATTRIBUTE_SET with operator 'AND' or 'OR'.
1242
1296
  */
1243
1297
  parseRefinement() {
1244
- return this.parseRefinementSet(() => this.parseSubRefinement(), 'refinement');
1298
+ return this.#nested(() => this.parseRefinementSet(() => this.parseSubRefinement(), 'refinement'));
1245
1299
  }
1246
1300
 
1247
1301
  parseRefinementSet(parseItem, what) {
@@ -1317,7 +1371,7 @@ class ECLParser {
1317
1371
  * (the contents of an attribute group - no nested groups)
1318
1372
  */
1319
1373
  parseAttributeSet() {
1320
- return this.parseRefinementSet(() => {
1374
+ return this.#nested(() => this.parseRefinementSet(() => {
1321
1375
  if (this.match(ECLTokenType.LPAREN) && !this.#parenIsAttributeName()) {
1322
1376
  this.advance();
1323
1377
  const inner = this.parseAttributeSet();
@@ -1325,7 +1379,7 @@ class ECLParser {
1325
1379
  return inner;
1326
1380
  }
1327
1381
  return this.parseAttribute();
1328
- }, 'attribute set');
1382
+ }, 'attribute set'));
1329
1383
  }
1330
1384
 
1331
1385
  parseAttributeGroup(cardinality = null) {
@@ -1430,6 +1484,14 @@ class ECLParser {
1430
1484
  };
1431
1485
  }
1432
1486
 
1487
+ if (!comparison) {
1488
+ // eclAttribute = [cardinality] [reverseFlag] eclAttributeName comparison - a
1489
+ // bare name is a syntax error. This is what "(A : B = C OR D)" trips on:
1490
+ // the OR binds inside the refinement, so D is an attribute name without a
1491
+ // comparison.
1492
+ this.error('Expected a comparison operator (=, !=, <, <=, >, >=) after the attribute name');
1493
+ }
1494
+
1433
1495
  return {
1434
1496
  type: ECLNodeType.ATTRIBUTE,
1435
1497
  cardinality,
@@ -2332,6 +2394,8 @@ class ECLValidator {
2332
2394
  }
2333
2395
 
2334
2396
  module.exports = {
2397
+ MAX_ECL_DEPTH,
2398
+ MAX_ECL_TERMS,
2335
2399
  ECLValidator,
2336
2400
  ECLLexer,
2337
2401
  ECLParser,
@@ -63,8 +63,47 @@ function editionCode(edition) {
63
63
  return e ? e.code : null;
64
64
  }
65
65
 
66
+ /**
67
+ * The editions snomed.info can resolve an edition-specific concept link for: the same list as
68
+ * SnomedUtilities.getEditionFromVersion in org.hl7.fhir.core. A code from any other edition
69
+ * links to http://snomed.info/id/<code>. Also the editions offered when an edition is chosen
70
+ * (the IPS module's national profiles), in this order.
71
+ */
72
+ const SNOMED_LINKABLE_EDITIONS = new Set([
73
+ '900000000000207008', // International
74
+ '449081005', // International Spanish
75
+ '11000221109', // Argentinian
76
+ '32506021000036107', // Australian (with drug extension)
77
+ '11000234105', // Austrian
78
+ '11000172109', // Belgian
79
+ '20621000087109', // Canadian English
80
+ '20611000087101', // Canadian Canadian French
81
+ '21000325107', // Chilean
82
+ '11000279109', // Czech
83
+ '554471000005108', // Danish
84
+ '11000181102', // Estonian
85
+ '11000229106', // Finnish
86
+ '11000274103', // German
87
+ '1121000189102', // Indian
88
+ '11000220105', // Irish
89
+ '11000146104', // Netherlands
90
+ '21000210109', // New Zealand
91
+ '51000202101', // Norwegian
92
+ '11000267109', // Republic of Korea (South Korea)
93
+ '900000001000122104', // Spanish National
94
+ '45991000052106', // Swedish
95
+ '2011000195101', // Swiss
96
+ '83821000000107', // UK
97
+ '999000021000000109', // UK Clinical
98
+ '5631000179106', // Uruguay
99
+ '731000124108', // US
100
+ '5991000124107', // US (with ICD-10-CM maps)
101
+ '827022005' // IPS Terminology
102
+ ]);
103
+
66
104
  module.exports = {
67
105
  EDITIONS,
106
+ SNOMED_LINKABLE_EDITIONS,
68
107
  editionName,
69
108
  editionCode
70
109
  };
@@ -13,6 +13,10 @@ const MAX_TERM_LENGTH = 1024;
13
13
  // Attribute values are themselves expanded, so a chain of definitions can descend a
14
14
  // long way; in real data it is a handful of levels.
15
15
  const MAX_NORMALISATION_DEPTH = 50;
16
+ // How deep bracketed attribute values may nest in an expression the parser accepts. The parser
17
+ // (and everything that walks the parsed expression) recurses once per level, so without a bound
18
+ // a hostile expression is a RangeError (stack overflow) rather than a parse error.
19
+ const MAX_EXPRESSION_DEPTH = 100;
16
20
 
17
21
  const NO_REFERENCE = 0xFFFFFFFF;
18
22
 
@@ -483,6 +487,7 @@ class SnomedExpressionParser {
483
487
  parse(source) {
484
488
  this.source = source;
485
489
  this.cursor = 0;
490
+ this.depth = 0;
486
491
 
487
492
  const result = new SnomedExpression();
488
493
  result.start = this.cursor;
@@ -681,9 +686,12 @@ class SnomedExpressionParser {
681
686
  this.ws();
682
687
 
683
688
  if (this.gchar('(')) {
689
+ this.depth++;
690
+ this.rule(this.depth <= MAX_EXPRESSION_DEPTH, `The expression nests more than ${MAX_EXPRESSION_DEPTH} levels deep`);
684
691
  const result = this.expression();
685
692
  this.ws();
686
693
  this.fixed(')');
694
+ this.depth--;
687
695
  return result;
688
696
  } else {
689
697
  const result = new SnomedExpression();
@@ -2951,6 +2959,7 @@ module.exports = {
2951
2959
 
2952
2960
  // Enums and constants
2953
2961
  SnomedServicesRenderOption,
2954
- SnomedRefinementGroupMatchState
2962
+ SnomedRefinementGroupMatchState,
2963
+ MAX_EXPRESSION_DEPTH
2955
2964
  };
2956
2965
 
@@ -0,0 +1,107 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * How each SNOMED CT cache file is loaded, from the tx module's "snomed" block in config.json:
5
+ *
6
+ * "snomed": {
7
+ * "load": "read" | "mmap", // the default for every edition (absent: "read")
8
+ * "prewarm": [ ... ], // map these, then read them through once in the background at startup
9
+ * "read": [ ... ] // read these into the process even when load is "mmap"
10
+ * }
11
+ *
12
+ * An entry names a cache file (e.g. "sct_us_20250901b.cache"), an edition version URI
13
+ * ("http://snomed.info/sct/731000124108/version/20250901"), or an edition URI
14
+ * ("http://snomed.info/sct/731000124108", every loaded version of the edition). "read" wins
15
+ * over "prewarm", which wins over "load".
16
+ *
17
+ * read - the file is read into this process (private memory, as before)
18
+ * mmap - the file is mapped read-only; pages are read from disk when first touched, and
19
+ * shared with every other process that maps the same file
20
+ * prewarm - mapped, and read through once in the background at startup so its pages are
21
+ * already in the shared page cache when the first requests arrive
22
+ */
23
+
24
+ const path = require('path');
25
+
26
+ const MODES = ['read', 'mmap'];
27
+
28
+ class SnomedLoadConfig {
29
+ /**
30
+ * @param {object} [block] - the "snomed" block of the tx module config
31
+ * @param {function(string)} [warn]
32
+ */
33
+ constructor(block, warn = () => {}) {
34
+ this.warn = warn;
35
+ block = block || {};
36
+ this.load = 'read';
37
+ if (block.load !== undefined) {
38
+ if (MODES.includes(block.load)) {
39
+ this.load = block.load;
40
+ } else {
41
+ warn(`snomed.load must be "read" or "mmap", not ${JSON.stringify(block.load)}; using "read"`);
42
+ }
43
+ }
44
+ this.lists = {};
45
+ for (const name of ['prewarm', 'read']) {
46
+ const v = block[name];
47
+ if (v === undefined) {
48
+ this.lists[name] = [];
49
+ } else if (Array.isArray(v) && v.every(e => typeof e === 'string' && e.trim())) {
50
+ this.lists[name] = v.map(e => e.trim());
51
+ } else {
52
+ warn(`snomed.${name} must be a list of cache file names or edition URIs; ignored`);
53
+ this.lists[name] = [];
54
+ }
55
+ }
56
+ for (const e of this.lists.prewarm) {
57
+ if (this.lists.read.includes(e)) {
58
+ warn(`snomed: ${e} is in both "prewarm" and "read"; it will be read`);
59
+ }
60
+ }
61
+ this.used = new Set();
62
+ }
63
+
64
+ /** True if any edition can end up mapped. */
65
+ mapsAnything() {
66
+ return this.load === 'mmap' || this.lists.prewarm.length > 0;
67
+ }
68
+
69
+ static #matches(entry, fileName, versionUri) {
70
+ if (entry === fileName) return true;
71
+ if (!versionUri) return false;
72
+ return entry === versionUri || versionUri.startsWith(entry.replace(/\/+$/, '') + '/version/');
73
+ }
74
+
75
+ #find(list, fileName, versionUri) {
76
+ const hit = this.lists[list].filter(e => SnomedLoadConfig.#matches(e, fileName, versionUri));
77
+ hit.forEach(e => this.used.add(e));
78
+ return hit.length > 0;
79
+ }
80
+
81
+ /**
82
+ * @param {string} filePath - the cache file
83
+ * @param {string|null} versionUri - its edition version URI, e.g. http://snomed.info/sct/900000000000207008/version/20250201
84
+ * @returns {'read'|'mmap'|'prewarm'}
85
+ */
86
+ modeFor(filePath, versionUri) {
87
+ const fileName = path.basename(filePath);
88
+ const read = this.#find('read', fileName, versionUri);
89
+ const prewarm = this.#find('prewarm', fileName, versionUri);
90
+ if (read) return 'read';
91
+ if (prewarm) return 'prewarm';
92
+ return this.load;
93
+ }
94
+
95
+ /** Warn about entries that matched no loaded edition. Call once every edition is loaded. */
96
+ checkUnused() {
97
+ for (const list of ['prewarm', 'read']) {
98
+ for (const e of this.lists[list]) {
99
+ if (!this.used.has(e)) {
100
+ this.warn(`snomed.${list}: "${e}" matches no SNOMED CT edition in the library`);
101
+ }
102
+ }
103
+ }
104
+ }
105
+ }
106
+
107
+ module.exports = { SnomedLoadConfig };
package/tx/sct/mmap.js ADDED
@@ -0,0 +1,109 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Read-only memory mapping of SNOMED CT cache files ("snomed": {"load": "mmap"} in config.json).
5
+ *
6
+ * Node has no mmap of its own, so this calls the C library's mmap() through koffi (an FFI
7
+ * module that ships prebuilt binaries for Linux glibc and musl, macOS and the BSDs, so
8
+ * nothing is compiled at install time). The mapping is never unmapped: a loaded edition
9
+ * stays loaded for the life of the process.
10
+ *
11
+ * mapFile() returns null where mapping is not available (Windows, koffi missing, mmap
12
+ * failing), and the caller then reads the file into memory as before. It says why through the
13
+ * warn function it is given (the server log), or console.warn where there is none.
14
+ */
15
+
16
+ const fs = require('fs');
17
+
18
+ const PROT_READ = 1;
19
+ const MAP_PRIVATE = 2; // same value on Linux, macOS and the BSDs
20
+ const MAP_FAILED = 0xFFFFFFFFFFFFFFFFn;
21
+
22
+ let libcMmap; // undefined: not tried yet; null: not available
23
+ let unavailable = null; // why libcMmap is null
24
+
25
+ function getMmap() {
26
+ if (libcMmap === undefined) {
27
+ libcMmap = null;
28
+ if (process.platform === 'win32' || !process.arch.endsWith('64')) {
29
+ unavailable = `not supported on ${process.platform}/${process.arch}`;
30
+ } else {
31
+ try {
32
+ const koffi = require('koffi');
33
+ const mmap = koffi.load(null).func('void *mmap(void *addr, size_t len, int prot, int flags, int fd, int64_t off)');
34
+ libcMmap = { koffi, mmap };
35
+ } catch (e) {
36
+ unavailable = e.message;
37
+ }
38
+ }
39
+ }
40
+ return libcMmap;
41
+ }
42
+
43
+ /** True if mapFile() can map files on this platform (it may still fail for a given file). */
44
+ function mappingAvailable() {
45
+ return getMmap() !== null;
46
+ }
47
+
48
+ /**
49
+ * @param {string} filePath
50
+ * @param {function(string)} [warn] - where to say why the file was not mapped
51
+ * @returns {Buffer|null} a read-only Buffer over the whole file, or null
52
+ */
53
+ function mapFile(filePath, warn = (msg) => console.warn(msg)) {
54
+ const lib = getMmap();
55
+ if (!lib) {
56
+ warn(`SNOMED CT: memory mapping is not available here (${unavailable}); reading ${filePath} into memory`);
57
+ return null;
58
+ }
59
+ const fd = fs.openSync(filePath, 'r');
60
+ try {
61
+ const size = fs.fstatSync(fd).size;
62
+ if (size === 0) return null;
63
+ const ptr = lib.mmap(null, size, PROT_READ, MAP_PRIVATE, fd, 0);
64
+ if (ptr === null || lib.koffi.address(ptr) === MAP_FAILED) {
65
+ warn(`SNOMED CT: could not map ${filePath} (errno ${lib.koffi.errno()}); reading it into memory`);
66
+ return null;
67
+ }
68
+ return Buffer.from(lib.koffi.view(ptr, size));
69
+ } finally {
70
+ fs.closeSync(fd); // the mapping keeps its own reference to the file
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Read a file through once, in the background, so its pages are in the page cache before
76
+ * requests need them (madvise(MADV_WILLNEED) is only a hint, and on Linux it read almost
77
+ * nothing of a mapped file). The reads go through one small buffer, so this costs no process
78
+ * memory; the pages land in the page cache that every mapping of the file shares.
79
+ *
80
+ * @param {string} filePath
81
+ * @returns {Promise<number>} milliseconds taken
82
+ */
83
+ async function prewarmFile(filePath) {
84
+ const start = Date.now();
85
+ const buf = Buffer.allocUnsafe(4 * 1024 * 1024);
86
+ const fh = await fs.promises.open(filePath, 'r');
87
+ try {
88
+ let pos = 0;
89
+ for (;;) {
90
+ const { bytesRead } = await fh.read(buf, 0, buf.length, pos);
91
+ if (bytesRead === 0) break;
92
+ pos += bytesRead;
93
+ }
94
+ } finally {
95
+ await fh.close();
96
+ }
97
+ return Date.now() - start;
98
+ }
99
+
100
+ /**
101
+ * For tests: replace the mmap binding ({koffi, mmap}, or null with a reason for "not
102
+ * available"), or with no arguments go back to the real one.
103
+ */
104
+ function _setMmapForTesting(lib, reason = 'disabled by a test') {
105
+ libcMmap = arguments.length === 0 ? undefined : lib;
106
+ unavailable = lib ? null : reason;
107
+ }
108
+
109
+ module.exports = { mapFile, prewarmFile, mappingAvailable, _setMmapForTesting };