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.
- package/ACCESSIBILITY.md +119 -0
- package/CHANGELOG.md +190 -9
- package/CONFIGURATION.md +305 -0
- package/LICENSE +29 -0
- package/README.md +29 -122
- package/cluster.js +1220 -0
- package/cluster.md +707 -0
- package/config-template.json +77 -8
- package/extension-tracker/extension-tracker-template.html +5 -8
- package/extension-tracker/extension-tracker.js +0 -2
- package/folder/folder.js +7 -6
- package/ips/analysis.js +560 -0
- package/ips/ips-template.html +5 -8
- package/ips/ips.js +331 -33
- package/ips/jurisdictions.js +65 -0
- package/ips/nationality.js +120 -0
- package/ips/pipeline.js +131 -17
- package/ips/presenter.js +1 -1
- package/ips/profile-editor.js +283 -4
- package/ips/readme.md +170 -20
- package/ips/render.js +769 -109
- package/ips/scripts.js +46 -0
- package/ips/store.js +123 -11
- package/ips/structure.js +247 -0
- package/ips/terminology.js +1 -1
- package/library/body-limit.js +22 -0
- package/library/cache-generation.js +15 -0
- package/library/cgroup-memory.js +133 -1
- package/library/cluster-counts.js +121 -0
- package/library/cluster-events.js +153 -0
- package/library/cluster-metrics.js +193 -0
- package/library/cluster-status.js +468 -0
- package/library/config-reader.js +96 -0
- package/library/daily-report.js +100 -2
- package/library/html-server.js +27 -1
- package/library/i18nsupport.js +23 -1
- package/library/keep-alive.js +18 -0
- package/library/languages.js +17 -3
- package/library/logger.js +31 -0
- package/library/memory-guard.js +428 -0
- package/library/native-memory.js +52 -0
- package/library/npm-audit.js +51 -25
- package/library/openapi-doc.js +7 -5
- package/library/package-manager.js +8 -0
- package/library/role.js +197 -0
- package/library/server-credentials.js +169 -0
- package/library/sqlite-utilities.js +59 -0
- package/library/ssrf-guard.js +100 -0
- package/library/static-response-stats.js +16 -0
- package/library/version-utilities.js +50 -0
- package/library/worker-count.js +192 -0
- package/nginx.md +30 -11
- package/npmprojector/indexer.js +18 -10
- package/package.json +11 -8
- package/packages/feed-check-page.js +117 -0
- package/packages/feed-check.js +279 -0
- package/packages/npm-check.js +57 -3
- package/packages/npm-signing.js +219 -0
- package/packages/openapi.yaml +207 -1
- package/packages/package-crawler.js +322 -41
- package/packages/packages-template.html +6 -8
- package/packages/packages.js +604 -57
- package/packages/readme.md +113 -1
- package/publisher/publisher-template.html +2 -5
- package/publisher/publisher.js +121 -110
- package/publisher/readme.md +75 -0
- package/registry/api.js +179 -1
- package/registry/crawler.js +86 -108
- package/registry/documentation/api.md +68 -2
- package/registry/documentation/configuration.md +34 -0
- package/registry/documentation/internals.md +4 -1
- package/registry/documentation/readme.md +3 -2
- package/registry/model.js +8 -0
- package/registry/openapi.yaml +84 -0
- package/registry/registry-template.html +5 -8
- package/registry/registry.js +58 -6
- package/root-bare-template.html +7 -4
- package/root-template.html +5 -8
- package/server.js +398 -198
- package/shl/shl.js +38 -37
- package/static/a11y/a11y-check.js +832 -0
- package/static/a11y/axe.min.js +12 -0
- package/static/assets/css/bootstrap-fhir.css +15 -15
- package/static/assets/images/flags/au.svg +18 -0
- package/static/assets/images/flags/unknown.svg +11 -0
- package/static/assets/images/flags/us.svg +63 -0
- package/static/assets/images/icon_audit.png +0 -0
- package/static/assets/images/icon_clinical.png +0 -0
- package/static/assets/images/icon_document.png +0 -0
- package/static/assets/images/icon_external.png +0 -0
- package/static/assets/images/icon_medication.png +0 -0
- package/static/assets/images/icon_organization.png +0 -0
- package/static/assets/images/icon_other.png +0 -0
- package/static/assets/images/icon_person.png +0 -0
- package/static/assets/images/icon_section.png +0 -0
- package/stats-db.js +45 -0
- package/stats.js +197 -10
- package/testing/testing-template.html +5 -8
- package/token/nginx_passport_setup.md +1 -1
- package/token/security_guide.md +1 -1
- package/token/token-template.html +2 -5
- package/token/token.js +40 -36
- package/translations/Messages.properties +19 -0
- package/tx/README.md +158 -4
- package/tx/closure/closure-store.js +126 -3
- package/tx/cm/cm-database.js +69 -57
- package/tx/cm/cm-package.js +50 -16
- package/tx/cs/cs-api.js +284 -19
- package/tx/cs/cs-api.md +41 -4
- package/tx/cs/cs-areacode.js +1 -1
- package/tx/cs/cs-country.js +17 -5
- package/tx/cs/cs-cpt.js +33 -16
- package/tx/cs/cs-cs.js +199 -24
- package/tx/cs/cs-currency.js +1 -1
- package/tx/cs/cs-icd11.js +21 -13
- package/tx/cs/cs-icf.js +22 -3
- package/tx/cs/cs-lang.js +2 -2
- package/tx/cs/cs-loinc.js +193 -67
- package/tx/cs/cs-mimetypes.js +12 -2
- package/tx/cs/cs-ndc.js +40 -25
- package/tx/cs/cs-omop.js +54 -29
- package/tx/cs/cs-rxnorm.js +300 -196
- package/tx/cs/cs-snomed.js +861 -95
- package/tx/cs/cs-ucum.js +14 -4
- package/tx/cs/cs-unii.js +18 -14
- package/tx/cs/cs-virtual.js +210 -0
- package/tx/ecosystem-resolver.js +413 -0
- package/tx/html/cluster-dash-metrics.liquid +26 -0
- package/tx/html/cluster-home-metrics.liquid +31 -0
- package/tx/html/cluster-metrics-script.liquid +251 -0
- package/tx/html/codesystem-operations.liquid +3 -3
- package/tx/html/ecl-panel.liquid +1 -1
- package/tx/html/external-metrics.liquid +17 -0
- package/tx/html/operations-form.liquid +17 -17
- package/tx/html/search-form.liquid +11 -11
- package/tx/html/status-line.liquid +58 -0
- package/tx/html/status-panel.liquid +140 -0
- package/tx/html/status-tasks.liquid +25 -0
- package/tx/html/tx-template.html +2 -5
- package/tx/importers/atc-to-fhir.js +17 -16
- package/tx/importers/export-icd11-codesystem.js +3 -2
- package/tx/importers/import-icd11.js +4 -3
- package/tx/importers/import-loinc.module.js +118 -56
- package/tx/importers/import-ndc.module.js +56 -44
- package/tx/importers/import-rxnorm.module.js +118 -79
- package/tx/importers/import-sct.module.js +125 -61
- package/tx/importers/import-unii.module.js +37 -36
- package/tx/importers/subset-loinc.module.js +49 -48
- package/tx/importers/subset-rxnorm.module.js +29 -28
- package/tx/importers/terminal.js +18 -0
- package/tx/importers/tx-import-base.js +13 -12
- package/tx/importers/tx-import-settings.js +5 -4
- package/tx/importers/tx-import.js +53 -52
- package/tx/library/canonical-resource.js +49 -0
- package/tx/library/codesystem.js +94 -5
- package/tx/library/designations.js +45 -2
- package/tx/library/extensions.js +13 -7
- package/tx/library/implicit-valuesets.js +66 -0
- package/tx/library/renderer.js +77 -21
- package/tx/library/sqlite-utilities.js +2 -0
- package/tx/library/tho-precedence.js +40 -0
- package/tx/library/ucum-parsers.js +11 -1
- package/tx/library/wait-for-cache.js +43 -0
- package/tx/library.js +321 -22
- package/tx/openapi-operations.js +6 -4
- package/tx/operation-context.js +460 -99
- package/tx/params.js +25 -0
- package/tx/problems.js +3 -5
- package/tx/provider.js +314 -67
- package/tx/resource-cache-sqlite.js +1050 -0
- package/tx/sct/documentation.md +45 -21
- package/tx/sct/ecl.js +81 -17
- package/tx/sct/editions.js +39 -0
- package/tx/sct/expressions.js +10 -1
- package/tx/sct/load-config.js +107 -0
- package/tx/sct/mmap.js +109 -0
- package/tx/sct/structures.js +175 -15
- package/tx/sct/text-index.js +5 -1
- package/tx/syndication/syndication-feed.js +2 -2
- package/tx/tx-html.js +44 -8
- package/tx/tx.fhir.org.yml +8 -8
- package/tx/tx.js +282 -80
- package/tx/usage-tracker.js +39 -11
- package/tx/vs/vs-api.js +19 -0
- package/tx/vs/vs-database.js +386 -62
- package/tx/vs/vs-package.js +44 -17
- package/tx/vs/vs-vsac.js +649 -101
- package/tx/workers/batch-validate.js +37 -10
- package/tx/workers/batch.js +6 -1
- package/tx/workers/cache-control.js +15 -4
- package/tx/workers/closure.js +156 -13
- package/tx/workers/compare.js +8 -5
- package/tx/workers/expand.js +1173 -289
- package/tx/workers/metadata.js +35 -0
- package/tx/workers/search.js +53 -1
- package/tx/workers/supplement-filters.js +145 -0
- package/tx/workers/translate.js +169 -4
- package/tx/workers/validate.js +223 -119
- package/tx/workers/worker.js +123 -31
- package/tx/xversion/xv-valueset.js +7 -4
- package/utilities/submit-test-report.js +3 -1
- package/utilities/tx-stress-test.js +482 -0
- package/vcl/readme.md +37 -0
- package/vcl/vcl-parser.js +15 -0
- package/xig/readme.md +52 -0
- package/xig/xig-template.html +5 -8
- package/xig/xig.js +238 -422
- package/changelog-template.md +0 -22
- package/registry/test-runner.js +0 -237
- package/release.sh +0 -80
- package/test-scripts/repro-re2-wasm-leak.js +0 -34
- package/tsconfig.eslint.json +0 -9
- package/tx/html/dash-metrics.liquid +0 -147
- package/tx/html/home-metrics.liquid +0 -225
package/tx/sct/documentation.md
CHANGED
|
@@ -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
|
-
-
|
|
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
|
|
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 (
|
|
185
|
-
-
|
|
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
|
-
|
|
542
|
-
|
|
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
|
|
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
|
|
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
|
|
730
|
-
//
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
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.
|
|
740
|
-
return
|
|
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,
|
package/tx/sct/editions.js
CHANGED
|
@@ -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
|
};
|
package/tx/sct/expressions.js
CHANGED
|
@@ -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 };
|