fhirsmith 0.18.2 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ACCESSIBILITY.md +119 -0
- package/CHANGELOG.md +149 -0
- 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 +132 -59
- package/ips/nationality.js +120 -0
- package/ips/pipeline.js +74 -21
- package/ips/presenter.js +1 -1
- package/ips/readme.md +132 -25
- package/ips/render.js +466 -77
- package/ips/store.js +79 -22
- 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/crawler.js +84 -108
- package/registry/documentation/configuration.md +34 -0
- package/registry/registry-template.html +5 -8
- package/registry/registry.js +5 -4
- 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 +154 -4
- package/tx/closure/closure-store.js +126 -3
- package/tx/cm/cm-database.js +69 -57
- package/tx/cm/cm-package.js +29 -1
- 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 +148 -14
- 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 +185 -66
- 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 +608 -64
- 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 +57 -58
- 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/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 +278 -42
- package/tx/resource-cache-sqlite.js +1050 -0
- package/tx/sct/ecl.js +47 -6
- 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 +166 -13
- 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 +27 -4
- package/tx/vs/vs-vsac.js +648 -100
- 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/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() {
|
|
@@ -607,6 +637,9 @@ class ECLParser {
|
|
|
607
637
|
}
|
|
608
638
|
|
|
609
639
|
parseSubExpressionConstraint() {
|
|
640
|
+
if (++this.terms > MAX_ECL_TERMS) {
|
|
641
|
+
this.error(`The expression has more than ${MAX_ECL_TERMS} terms`);
|
|
642
|
+
}
|
|
610
643
|
// Handle constraint operators
|
|
611
644
|
let operator = null;
|
|
612
645
|
if (this.match(
|
|
@@ -701,7 +734,8 @@ class ECLParser {
|
|
|
701
734
|
}
|
|
702
735
|
|
|
703
736
|
/**
|
|
704
|
-
* Parse one "{{ ... }}" block
|
|
737
|
+
* Parse one "{{ ... }}" block (the D of a description filter is optional). Returns
|
|
738
|
+
* { kind: 'M'|'D'|'C'|'+', filters } where
|
|
705
739
|
* filters is only populated for member filters; the other kinds keep their
|
|
706
740
|
* raw token text so the evaluator can report them as unsupported.
|
|
707
741
|
*/
|
|
@@ -714,8 +748,13 @@ class ECLParser {
|
|
|
714
748
|
} else if (this.match(ECLTokenType.SCTID) && /^[MDCmdc]$/.test(this.current.value)) {
|
|
715
749
|
kind = this.current.value.toUpperCase();
|
|
716
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';
|
|
717
756
|
} else {
|
|
718
|
-
this.error('Expected M, D, C or
|
|
757
|
+
this.error('Expected M, D, C, + or a description filter after {{');
|
|
719
758
|
}
|
|
720
759
|
|
|
721
760
|
if (kind === 'C' || kind === 'D') {
|
|
@@ -1256,7 +1295,7 @@ class ECLParser {
|
|
|
1256
1295
|
* returned as-is; otherwise an ATTRIBUTE_SET with operator 'AND' or 'OR'.
|
|
1257
1296
|
*/
|
|
1258
1297
|
parseRefinement() {
|
|
1259
|
-
return this.parseRefinementSet(() => this.parseSubRefinement(), 'refinement');
|
|
1298
|
+
return this.#nested(() => this.parseRefinementSet(() => this.parseSubRefinement(), 'refinement'));
|
|
1260
1299
|
}
|
|
1261
1300
|
|
|
1262
1301
|
parseRefinementSet(parseItem, what) {
|
|
@@ -1332,7 +1371,7 @@ class ECLParser {
|
|
|
1332
1371
|
* (the contents of an attribute group - no nested groups)
|
|
1333
1372
|
*/
|
|
1334
1373
|
parseAttributeSet() {
|
|
1335
|
-
return this.parseRefinementSet(() => {
|
|
1374
|
+
return this.#nested(() => this.parseRefinementSet(() => {
|
|
1336
1375
|
if (this.match(ECLTokenType.LPAREN) && !this.#parenIsAttributeName()) {
|
|
1337
1376
|
this.advance();
|
|
1338
1377
|
const inner = this.parseAttributeSet();
|
|
@@ -1340,7 +1379,7 @@ class ECLParser {
|
|
|
1340
1379
|
return inner;
|
|
1341
1380
|
}
|
|
1342
1381
|
return this.parseAttribute();
|
|
1343
|
-
}, 'attribute set');
|
|
1382
|
+
}, 'attribute set'));
|
|
1344
1383
|
}
|
|
1345
1384
|
|
|
1346
1385
|
parseAttributeGroup(cardinality = null) {
|
|
@@ -2355,6 +2394,8 @@ class ECLValidator {
|
|
|
2355
2394
|
}
|
|
2356
2395
|
|
|
2357
2396
|
module.exports = {
|
|
2397
|
+
MAX_ECL_DEPTH,
|
|
2398
|
+
MAX_ECL_TERMS,
|
|
2358
2399
|
ECLValidator,
|
|
2359
2400
|
ECLLexer,
|
|
2360
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 };
|