fhirsmith 0.10.1 → 0.11.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/CHANGELOG.md +36 -0
- package/changelog-template.md +22 -0
- package/library/logger.js +8 -1
- package/library/package-manager.js +40 -2
- package/npmprojector/watcher.js +1 -1
- package/package.json +3 -2
- package/packages/packages.js +28 -15
- package/publisher/publisher.js +8 -8
- package/registry/api.js +92 -31
- package/registry/crawler.js +15 -1
- package/registry/model.js +158 -8
- package/registry/readme.md +5 -13
- package/registry/registry.js +87 -46
- package/registry/test-runner.js +1 -1
- package/server.js +8 -1
- package/token/token.js +1 -1
- package/translations/Messages.properties +3 -1
- package/translations/rendering-phrases.properties +1227 -1218
- package/translations/rendering-phrases_ar.properties +1097 -1091
- package/translations/rendering-phrases_fr.properties +626 -624
- package/translations/rendering-phrases_nl.properties +976 -970
- package/translations/rendering-phrases_pt.properties +1026 -1020
- package/translations/rendering-phrases_ru.properties +1100 -1094
- package/tsconfig.eslint.json +9 -0
- package/tx/cs/cs-api.js +57 -4
- package/tx/cs/cs-cs.js +98 -25
- package/tx/cs/cs-hgvs.js +4 -0
- package/tx/cs/cs-lang.js +8 -0
- package/tx/cs/cs-mimetypes.js +4 -0
- package/tx/cs/cs-snomed.js +530 -75
- package/tx/cs/cs-uri.js +4 -0
- package/tx/data/snomed-subset.txt +40 -3
- package/tx/importers/import-sct.module.js +18 -0
- package/tx/library/designations.js +3 -5
- package/tx/library/renderer.js +227 -30
- package/tx/library.js +5 -1
- package/tx/operation-context.js +40 -3
- package/tx/provider.js +38 -9
- package/tx/sct/ecl.js +30 -11
- package/tx/sct/expressions.js +28 -1
- package/tx/sct/structures.js +5 -5
- package/tx/tx-html.js +24 -15
- package/tx/tx.fhir.org.yml +1 -4
- package/tx/vs/vs-package.js +52 -0
- package/tx/workers/batch-validate.js +125 -18
- package/tx/workers/cache-control.js +31 -2
- package/tx/workers/expand.js +50 -32
- package/tx/workers/metadata.js +27 -6
- package/tx/workers/search.js +7 -1
- package/tx/workers/validate.js +30 -18
- package/tx/workers/worker.js +30 -7
- package/tx/xversion/xv-valueset.js +5 -2
- package/xig/xig.js +3 -3
package/tx/operation-context.js
CHANGED
|
@@ -118,6 +118,19 @@ class ResourceCache {
|
|
|
118
118
|
return this.cache.has(cacheId);
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Whether a cache is sealed. A sealed cache holds only the resources it was
|
|
123
|
+
* created with (at $cache-control?mode=start) and does not grow as further
|
|
124
|
+
* resources are seen on subsequent operations. An unsealed cache accumulates
|
|
125
|
+
* every resource it sees. Unknown/absent cache-ids report false.
|
|
126
|
+
* @param {string} cacheId - The cache identifier
|
|
127
|
+
* @returns {boolean}
|
|
128
|
+
*/
|
|
129
|
+
isSealed(cacheId) {
|
|
130
|
+
const entry = this.cache.get(cacheId);
|
|
131
|
+
return entry ? !!entry.sealed : false;
|
|
132
|
+
}
|
|
133
|
+
|
|
121
134
|
/**
|
|
122
135
|
* Add resources to a cache-id (merges with existing)
|
|
123
136
|
* @param {string} cacheId - The cache identifier
|
|
@@ -126,6 +139,18 @@ class ResourceCache {
|
|
|
126
139
|
add(cacheId, resources) {
|
|
127
140
|
if (!resources || resources.length === 0) return;
|
|
128
141
|
|
|
142
|
+
// These resources are being retained under a cache-id (they persist across
|
|
143
|
+
// the batch), so mark them cached: their filter elements survive, and a
|
|
144
|
+
// provider may memoise resolved filter analysis on them (see filter()/fc).
|
|
145
|
+
// Inline tx-resources that never enter this pool stay unmarked.
|
|
146
|
+
for (const resource of resources) {
|
|
147
|
+
if (resource) {
|
|
148
|
+
// non-enumerable: internal marker only, must never serialise into a
|
|
149
|
+
// response or be copied by structuredClone/spread when a VS is cloned.
|
|
150
|
+
Object.defineProperty(resource, 'isCached', { value: true, enumerable: false, configurable: true, writable: true });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
129
154
|
const entry = this.cache.get(cacheId) || { resources: [], lastUsed: Date.now(), concepts: 0 };
|
|
130
155
|
|
|
131
156
|
// Merge resources, avoiding duplicates by url+version. Keep the entry's concept
|
|
@@ -158,9 +183,20 @@ class ResourceCache {
|
|
|
158
183
|
* Set resources for a cache-id (replaces existing)
|
|
159
184
|
* @param {string} cacheId - The cache identifier
|
|
160
185
|
* @param {Array} resources - Resources to set
|
|
186
|
+
* @param {boolean} [sealed=false] - If true, the cache is fixed at these
|
|
187
|
+
* resources and will not grow when further resources are seen later.
|
|
161
188
|
*/
|
|
162
|
-
set(cacheId, resources) {
|
|
163
|
-
this.log.info(`cache-id '${cacheId}': set (replace all) with ${resources.length} resource(s): ${resources.map(r => this._resourceKey(r)).join(', ')}`);
|
|
189
|
+
set(cacheId, resources, sealed = false) {
|
|
190
|
+
this.log.info(`cache-id '${cacheId}': set (replace all, sealed=${!!sealed}) with ${resources.length} resource(s): ${resources.map(r => this._resourceKey(r)).join(', ')}`);
|
|
191
|
+
// Retained under a cache-id - mark cached so providers may memoise resolved
|
|
192
|
+
// filter analysis on their filter elements (see filter()/fc).
|
|
193
|
+
for (const resource of resources) {
|
|
194
|
+
if (resource) {
|
|
195
|
+
// non-enumerable: internal marker only, must never serialise into a
|
|
196
|
+
// response or be copied by structuredClone/spread when a VS is cloned.
|
|
197
|
+
Object.defineProperty(resource, 'isCached', { value: true, enumerable: false, configurable: true, writable: true });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
164
200
|
// Drop the old entry's contribution, then count the replacement.
|
|
165
201
|
const existing = this.cache.get(cacheId);
|
|
166
202
|
if (existing) {
|
|
@@ -174,7 +210,8 @@ class ResourceCache {
|
|
|
174
210
|
this.cache.set(cacheId, {
|
|
175
211
|
resources: [...resources],
|
|
176
212
|
lastUsed: Date.now(),
|
|
177
|
-
concepts
|
|
213
|
+
concepts,
|
|
214
|
+
sealed: !!sealed
|
|
178
215
|
});
|
|
179
216
|
this._trackMax();
|
|
180
217
|
}
|
package/tx/provider.js
CHANGED
|
@@ -174,13 +174,7 @@ class Provider {
|
|
|
174
174
|
for (const resource of resources) {
|
|
175
175
|
const cs = new CodeSystem(await contentLoader.loadFile(resource, contentLoader.fhirVersion()));
|
|
176
176
|
cs.sourcePackage = contentLoader.pid();
|
|
177
|
-
|
|
178
|
-
if (!existing || cs.isMoreRecent(existing)) {
|
|
179
|
-
this.codeSystems.set(cs.url, cs);
|
|
180
|
-
}
|
|
181
|
-
if (cs.version) {
|
|
182
|
-
this.codeSystems.set(cs.vurl, cs);
|
|
183
|
-
}
|
|
177
|
+
this.addCodeSystem(cs);
|
|
184
178
|
}
|
|
185
179
|
const vs = new PackageValueSetProvider(contentLoader);
|
|
186
180
|
await vs.initialize();
|
|
@@ -486,13 +480,48 @@ class Provider {
|
|
|
486
480
|
}
|
|
487
481
|
}
|
|
488
482
|
|
|
483
|
+
/**
|
|
484
|
+
* True if the source package id (e.g. "hl7.terminology.r4#6.0.2") is a
|
|
485
|
+
* terminology.hl7.org (THO/UTG) package.
|
|
486
|
+
*/
|
|
487
|
+
static #isTHOPackage(pid) {
|
|
488
|
+
return pid != null && pid.startsWith("hl7.terminology");
|
|
489
|
+
}
|
|
490
|
+
|
|
489
491
|
addCodeSystem(cs) {
|
|
492
|
+
// Special case, mirroring CanonicalResourceManager.see() in the Java core:
|
|
493
|
+
// when content moved from the FHIR core packages to terminology.hl7.org,
|
|
494
|
+
// the versions went backwards (e.g. coverage-selfpay is 4.0.1 in
|
|
495
|
+
// hl7.fhir.r4.core (2019) but 1.0.1 in hl7.terminology (2024)), so plain
|
|
496
|
+
// version precedence would leave the stale core copy as the default. A
|
|
497
|
+
// resource from an hl7.terminology package displaces any same-URL
|
|
498
|
+
// resource that came from a FHIR core package, regardless of version.
|
|
499
|
+
if (Provider.#isTHOPackage(cs.sourcePackage)) {
|
|
500
|
+
for (const [key, t] of [...this.codeSystems]) {
|
|
501
|
+
// only the bare-url (default) entry is displaced; explicitly versioned
|
|
502
|
+
// entries (key = url|version) stay addressable, because the core packages
|
|
503
|
+
// carry historical versions (e.g. the normalized R4 v2 tables 0006/0360/0391
|
|
504
|
+
// at 2.1/2.3.1/2.4/2.6/2.7) that hl7.terminology does not publish at all
|
|
505
|
+
if (key === t.url && t.url === cs.url && VersionUtilities.isCorePackage(t.sourcePackage)) {
|
|
506
|
+
this.codeSystems.delete(key);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
490
510
|
const existing = this.codeSystems.get(cs.url);
|
|
491
|
-
|
|
511
|
+
// the reverse guard: a core-package resource never displaces a THO one
|
|
512
|
+
const yieldsToTHO = existing && Provider.#isTHOPackage(existing.sourcePackage)
|
|
513
|
+
&& VersionUtilities.isCorePackage(cs.sourcePackage);
|
|
514
|
+
if (!existing || (!yieldsToTHO && cs.isMoreRecent(existing))) {
|
|
492
515
|
this.codeSystems.set(cs.url, cs);
|
|
493
516
|
}
|
|
494
517
|
if (cs.version) {
|
|
495
|
-
|
|
518
|
+
// a versioned entry only yields to THO when THO itself provides that exact version
|
|
519
|
+
const vExisting = this.codeSystems.get(cs.vurl);
|
|
520
|
+
const vYieldsToTHO = vExisting && Provider.#isTHOPackage(vExisting.sourcePackage)
|
|
521
|
+
&& VersionUtilities.isCorePackage(cs.sourcePackage);
|
|
522
|
+
if (!vYieldsToTHO) {
|
|
523
|
+
this.codeSystems.set(cs.vurl, cs);
|
|
524
|
+
}
|
|
496
525
|
}
|
|
497
526
|
}
|
|
498
527
|
|
package/tx/sct/ecl.js
CHANGED
|
@@ -472,8 +472,21 @@ class ECLParser {
|
|
|
472
472
|
parseCompoundExpressionConstraint() {
|
|
473
473
|
let left = this.parseRefinedExpressionConstraint();
|
|
474
474
|
|
|
475
|
+
// ECL 6.4: conjunction (AND), disjunction (OR) and exclusion (MINUS)
|
|
476
|
+
// operators must not be mixed within a single ungrouped compound expression;
|
|
477
|
+
// round brackets are mandatory to disambiguate. Track the operator used in
|
|
478
|
+
// this compound sequence and reject a mix (e.g. "<< A OR << B AND << C").
|
|
479
|
+
let compoundOp = null;
|
|
480
|
+
|
|
475
481
|
while (this.match(ECLTokenType.AND, ECLTokenType.OR, ECLTokenType.MINUS)) {
|
|
476
482
|
const operator = this.current;
|
|
483
|
+
if (compoundOp !== null && operator.type !== compoundOp) {
|
|
484
|
+
throw new Error(
|
|
485
|
+
'Ambiguous ECL expression: conjunction, disjunction and exclusion ' +
|
|
486
|
+
'operators cannot be mixed without parentheses (ECL 6.4). ' +
|
|
487
|
+
'Use round brackets to disambiguate.');
|
|
488
|
+
}
|
|
489
|
+
compoundOp = operator.type;
|
|
477
490
|
this.advance();
|
|
478
491
|
const right = this.parseRefinedExpressionConstraint();
|
|
479
492
|
|
|
@@ -616,12 +629,19 @@ class ECLParser {
|
|
|
616
629
|
const attributes = [];
|
|
617
630
|
|
|
618
631
|
do {
|
|
632
|
+
// A leading cardinality may prefix either a single attribute
|
|
633
|
+
// ([m..n] attr = v) or an attribute group ([m..n] { ... }). Parse it once,
|
|
634
|
+
// then dispatch on whether a group ({) or an attribute follows.
|
|
635
|
+
let cardinality = null;
|
|
636
|
+
if (this.match(ECLTokenType.LBRACKET)) {
|
|
637
|
+
cardinality = this.parseCardinality();
|
|
638
|
+
}
|
|
619
639
|
if (this.match(ECLTokenType.LBRACE)) {
|
|
620
640
|
// Attribute group
|
|
621
|
-
attributes.push(this.parseAttributeGroup());
|
|
641
|
+
attributes.push(this.parseAttributeGroup(cardinality));
|
|
622
642
|
} else {
|
|
623
643
|
// Single attribute
|
|
624
|
-
attributes.push(this.parseAttribute());
|
|
644
|
+
attributes.push(this.parseAttribute(cardinality));
|
|
625
645
|
}
|
|
626
646
|
} while (this.match(ECLTokenType.COMMA) && (this.advance(), true));
|
|
627
647
|
|
|
@@ -635,11 +655,10 @@ class ECLParser {
|
|
|
635
655
|
};
|
|
636
656
|
}
|
|
637
657
|
|
|
638
|
-
parseAttributeGroup() {
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
if (this.match(ECLTokenType.LBRACKET)) {
|
|
658
|
+
parseAttributeGroup(cardinality = null) {
|
|
659
|
+
// Cardinality may have been parsed by the caller (parseAttributeSet); if
|
|
660
|
+
// not, check for it here (before the opening brace).
|
|
661
|
+
if (cardinality === null && this.match(ECLTokenType.LBRACKET)) {
|
|
643
662
|
cardinality = this.parseCardinality();
|
|
644
663
|
}
|
|
645
664
|
|
|
@@ -659,12 +678,12 @@ class ECLParser {
|
|
|
659
678
|
};
|
|
660
679
|
}
|
|
661
680
|
|
|
662
|
-
parseAttribute() {
|
|
663
|
-
let cardinality = null;
|
|
681
|
+
parseAttribute(cardinality = null) {
|
|
664
682
|
let reverse = false;
|
|
665
683
|
|
|
666
|
-
//
|
|
667
|
-
|
|
684
|
+
// Cardinality may have been parsed by the caller (parseAttributeSet); if
|
|
685
|
+
// not, check for it here (it must come before the attribute name).
|
|
686
|
+
if (cardinality === null && this.match(ECLTokenType.LBRACKET)) {
|
|
668
687
|
cardinality = this.parseCardinality();
|
|
669
688
|
}
|
|
670
689
|
|
package/tx/sct/expressions.js
CHANGED
|
@@ -1534,6 +1534,33 @@ class SnomedExpressionServices {
|
|
|
1534
1534
|
* List all display names for a concept
|
|
1535
1535
|
* Equivalent to Pascal ListDisplayNames procedure
|
|
1536
1536
|
*/
|
|
1537
|
+
// Key concept indexes for preferred-term selection (US English is the global
|
|
1538
|
+
// default language reference set). Mirrors the logic in cs-snomed.js so both
|
|
1539
|
+
// display paths agree.
|
|
1540
|
+
_displayConstants() {
|
|
1541
|
+
if (this._dispConst) return this._dispConst;
|
|
1542
|
+
const idx = (id) => { const r = this.concepts.findConcept(id); return r.found ? r.index : -1; };
|
|
1543
|
+
this._dispConst = {
|
|
1544
|
+
usRefset: idx(900000000000509007n),
|
|
1545
|
+
preferred: idx(900000000000548007n),
|
|
1546
|
+
synonym: idx(900000000000013009n)
|
|
1547
|
+
};
|
|
1548
|
+
return this._dispConst;
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
_isPreferredSynonym(description) {
|
|
1552
|
+
const K = this._displayConstants();
|
|
1553
|
+
if (description.kind !== K.synonym) return false;
|
|
1554
|
+
if (!description.valueses) return false;
|
|
1555
|
+
const valueses = this.refs.getReferences(description.valueses);
|
|
1556
|
+
if (!valueses) return false;
|
|
1557
|
+
for (let i = 0; i < valueses.length; i++) {
|
|
1558
|
+
const vals = this.refs.getReferences(valueses[i]); // [acceptabilityIndex, type]
|
|
1559
|
+
if (vals && vals.length > 0 && vals[0] === K.preferred) return true;
|
|
1560
|
+
}
|
|
1561
|
+
return false;
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1537
1564
|
listDisplayNames(conceptIndex, languageFilter = 0) {
|
|
1538
1565
|
const designations = [];
|
|
1539
1566
|
|
|
@@ -1558,7 +1585,7 @@ class SnomedExpressionServices {
|
|
|
1558
1585
|
const term = this.strings.getEntry(description.iDesc).trim();
|
|
1559
1586
|
|
|
1560
1587
|
designations.push({
|
|
1561
|
-
isPreferred:
|
|
1588
|
+
isPreferred: this._isPreferredSynonym(description), // preferred synonym in US English
|
|
1562
1589
|
isActive: true,
|
|
1563
1590
|
languageCode: this.codeForLanguage(description.lang),
|
|
1564
1591
|
term: term,
|
package/tx/sct/structures.js
CHANGED
|
@@ -719,6 +719,11 @@ class SnomedConceptList {
|
|
|
719
719
|
this.master.writeUInt32LE(value, index + 13);
|
|
720
720
|
}
|
|
721
721
|
|
|
722
|
+
setModuleId(index, moduleIndex) {
|
|
723
|
+
this.#checkPostBuildAccess(index);
|
|
724
|
+
this.master.writeUInt32LE(moduleIndex, index + 36);
|
|
725
|
+
}
|
|
726
|
+
|
|
722
727
|
setInbounds(index, value) {
|
|
723
728
|
this.#checkPostBuildAccess(index);
|
|
724
729
|
this.master.writeUInt32LE(value, index + 17);
|
|
@@ -749,11 +754,6 @@ class SnomedConceptList {
|
|
|
749
754
|
this.master.writeUInt16LE(effectiveTime, index + 34);
|
|
750
755
|
}
|
|
751
756
|
|
|
752
|
-
setModuleId(index, value) {
|
|
753
|
-
this.#checkPostBuildAccess(index);
|
|
754
|
-
this.master.writeUInt32LE(value, index + 36);
|
|
755
|
-
}
|
|
756
|
-
|
|
757
757
|
setStatus(index, value) {
|
|
758
758
|
this.#checkPostBuildAccess(index);
|
|
759
759
|
this.master.writeUInt32LE(value, index + 40);
|
package/tx/tx-html.js
CHANGED
|
@@ -64,6 +64,28 @@ function loadTemplate() {
|
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Check if a request wants HTML back (by _format/format param, else Accept header).
|
|
69
|
+
* Module-level so non-rendering code (e.g. the search worker choosing a default
|
|
70
|
+
* page size) can make the same call the response renderer will make.
|
|
71
|
+
*/
|
|
72
|
+
function acceptsHtml(req) {
|
|
73
|
+
let _fmt = req.query._format || req.query.format || req.body?._format;
|
|
74
|
+
if (_fmt && typeof _fmt !== 'string') {
|
|
75
|
+
_fmt = null;
|
|
76
|
+
}
|
|
77
|
+
if (_fmt && (_fmt == 'html' || _fmt.startsWith('html/'))) {
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
if (!_fmt) {
|
|
81
|
+
_fmt = req.headers.accept || '';
|
|
82
|
+
}
|
|
83
|
+
if (typeof _fmt !== 'string') {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
return _fmt.includes('text/html');
|
|
87
|
+
}
|
|
88
|
+
|
|
67
89
|
|
|
68
90
|
class TxHtmlRenderer {
|
|
69
91
|
renderer;
|
|
@@ -98,20 +120,7 @@ class TxHtmlRenderer {
|
|
|
98
120
|
* Check if request accepts HTML
|
|
99
121
|
*/
|
|
100
122
|
acceptsHtml(req) {
|
|
101
|
-
|
|
102
|
-
if (_fmt && typeof _fmt !== 'string') {
|
|
103
|
-
_fmt = null;
|
|
104
|
-
}
|
|
105
|
-
if (_fmt && (_fmt == 'html' || _fmt.startsWith('html/'))) {
|
|
106
|
-
return true;
|
|
107
|
-
}
|
|
108
|
-
if (!_fmt) {
|
|
109
|
-
_fmt = req.headers.accept || '';
|
|
110
|
-
}
|
|
111
|
-
if (typeof _fmt !== 'string') {
|
|
112
|
-
return false;
|
|
113
|
-
}
|
|
114
|
-
return _fmt.includes('text/html');
|
|
123
|
+
return acceptsHtml(req);
|
|
115
124
|
}
|
|
116
125
|
|
|
117
126
|
/**
|
|
@@ -1362,5 +1371,5 @@ class TxHtmlRenderer {
|
|
|
1362
1371
|
}
|
|
1363
1372
|
|
|
1364
1373
|
module.exports = {
|
|
1365
|
-
TxHtmlRenderer, loadTemplate
|
|
1374
|
+
TxHtmlRenderer, loadTemplate, acceptsHtml
|
|
1366
1375
|
};
|
package/tx/tx.fhir.org.yml
CHANGED
|
@@ -18,9 +18,6 @@ sources:
|
|
|
18
18
|
- unii:unii_20240622.db
|
|
19
19
|
- snomed:sct_intl_20240201.cache
|
|
20
20
|
- snomed!:sct_intl_20250201.cache
|
|
21
|
-
# - snomed:sct_se_20231130.cache
|
|
22
|
-
# - snomed:sct_au_20230731.cache
|
|
23
|
-
# - snomed:sct_be_20231115.cache
|
|
24
21
|
- snomed:sct_ch_20230607.cache
|
|
25
22
|
- snomed:sct_dk_20260331.cache
|
|
26
23
|
- snomed:sct_ips_20241216.cache
|
|
@@ -29,7 +26,7 @@ sources:
|
|
|
29
26
|
- snomed:sct_us_20230301.cache
|
|
30
27
|
- snomed:sct_us_20240301.cache
|
|
31
28
|
- snomed:sct_us_20250901.cache
|
|
32
|
-
- snomed:
|
|
29
|
+
- snomed:sct_test_20250909.cache
|
|
33
30
|
- cpt:CodeSystem-cpt.db|cpt-2023-fragment-0.1.db
|
|
34
31
|
- omop:omop_v20250227.db
|
|
35
32
|
- npm:hl7.terminology
|
package/tx/vs/vs-package.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const path = require('path');
|
|
2
|
+
const fs = require('fs').promises;
|
|
2
3
|
const { AbstractValueSetProvider } = require('./vs-api');
|
|
3
4
|
const { PackageContentLoader } = require('../../library/package-manager');
|
|
4
5
|
const { ValueSetDatabase } = require('./vs-database');
|
|
@@ -51,9 +52,60 @@ class PackageValueSetProvider extends AbstractValueSetProvider {
|
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
this.valueSetMap = await this.database.loadAllValueSets(this.sourcePackage());
|
|
55
|
+
|
|
56
|
+
// Self-heal caches built before pipe-versioned canonical urls were normalized at
|
|
57
|
+
// load time (the R4 v2 tables 0006/0360/0391 - see normalizeVersionedCanonicals in
|
|
58
|
+
// package-manager.js): if any cached value set still carries a "system|version"
|
|
59
|
+
// include or a pipe in its url, rebuild this cache from the package.
|
|
60
|
+
if (dbExists && this.#hasPipeVersionedCanonicals()) {
|
|
61
|
+
await this.database.close();
|
|
62
|
+
await fs.rm(this.dbPath, { force: true });
|
|
63
|
+
this.database = new ValueSetDatabase(this.dbPath);
|
|
64
|
+
await this.database.create();
|
|
65
|
+
await this._populateDatabase();
|
|
66
|
+
this.valueSetMap = await this.database.loadAllValueSets(this.sourcePackage());
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Mark each value set as cached as it enters the store: it is retained in
|
|
70
|
+
// memory for the process lifetime, so its compose.include.filter elements
|
|
71
|
+
// persist and a provider may memoise resolved filter analysis on them
|
|
72
|
+
// (see CodeSystemProvider.filter()'s `fc` parameter).
|
|
73
|
+
for (const vs of this.valueSetMap.values()) {
|
|
74
|
+
if (vs) {
|
|
75
|
+
// non-enumerable: internal marker only, must never serialise into a
|
|
76
|
+
// response or be copied by structuredClone/spread when a VS is cloned.
|
|
77
|
+
Object.defineProperty(vs, 'isCached', { value: true, enumerable: false, configurable: true, writable: true });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
54
80
|
this.initialized = true;
|
|
55
81
|
}
|
|
56
82
|
|
|
83
|
+
/**
|
|
84
|
+
* True if any loaded value set still has a pipe-versioned canonical (url or
|
|
85
|
+
* compose include/exclude system) - the signature of a cache written before
|
|
86
|
+
* load-time normalization existed.
|
|
87
|
+
*/
|
|
88
|
+
#hasPipeVersionedCanonicals() {
|
|
89
|
+
for (const vs of this.valueSetMap.values()) {
|
|
90
|
+
if (!vs) {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (typeof vs.url === 'string' && vs.url.includes('|')) {
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
if (vs.compose) {
|
|
97
|
+
for (const list of [vs.compose.include, vs.compose.exclude]) {
|
|
98
|
+
for (const inc of list || []) {
|
|
99
|
+
if (typeof inc.system === 'string' && inc.system.includes('|')) {
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
|
|
57
109
|
async close() {
|
|
58
110
|
await this.database.close();
|
|
59
111
|
}
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
//
|
|
2
|
-
// Validate Worker - Handles $validate-code operations
|
|
2
|
+
// Batch Validate Worker - Handles $batch-validate-code operations
|
|
3
3
|
//
|
|
4
|
-
// GET /
|
|
5
|
-
// POST /CodeSystem/$validate-code
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
4
|
+
// GET/POST /ValueSet/$batch-validate-code - batch of ValueSet $validate-code
|
|
5
|
+
// GET/POST /CodeSystem/$batch-validate-code - batch of CodeSystem $validate-code
|
|
6
|
+
//
|
|
7
|
+
// A batch is a Parameters whose `validation` entries each carry a single
|
|
8
|
+
// $validate-code request (as a nested Parameters). Shared inputs may be supplied
|
|
9
|
+
// once at the top level ("globals") and are applied to every entry. Entries are
|
|
10
|
+
// dispatched per-shape (a url/valueSet entry validates against a value set; a
|
|
11
|
+
// system/codeSystem entry validates against a code system), so both batch routes
|
|
12
|
+
// share one implementation and even a mixed batch is handled correctly.
|
|
12
13
|
//
|
|
13
14
|
|
|
14
15
|
const { TerminologyWorker } = require('./worker');
|
|
@@ -30,12 +31,20 @@ class BatchValidateWorker extends TerminologyWorker {
|
|
|
30
31
|
*/
|
|
31
32
|
constructor(opContext, log, provider, languages, i18n) {
|
|
32
33
|
super(opContext, log, provider, languages, i18n);
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
this.
|
|
38
|
-
|
|
34
|
+
// "Global" parameters may be supplied once at the top level and are applied to
|
|
35
|
+
// every entry that doesn't override them. tx-resource is always shared. The
|
|
36
|
+
// primary differs by operation: a ValueSet batch shares url/valueSet, a
|
|
37
|
+
// CodeSystem batch shares system/codeSystem.
|
|
38
|
+
this.valueSetGlobalNames = new Set([
|
|
39
|
+
"tx-resource", "url", "valueSet", "lenient-display-validation",
|
|
40
|
+
"__Accept-Language", "__Content-Language"
|
|
41
|
+
]);
|
|
42
|
+
this.codeSystemGlobalNames = new Set([
|
|
43
|
+
"tx-resource", "system", "codeSystem", "lenient-display-validation",
|
|
44
|
+
"__Accept-Language", "__Content-Language"
|
|
45
|
+
]);
|
|
46
|
+
// Back-compat: `globalNames` was the (ValueSet) set before CodeSystem batches.
|
|
47
|
+
this.globalNames = this.valueSetGlobalNames;
|
|
39
48
|
}
|
|
40
49
|
|
|
41
50
|
/**
|
|
@@ -46,18 +55,43 @@ class BatchValidateWorker extends TerminologyWorker {
|
|
|
46
55
|
return 'batch-validate-code';
|
|
47
56
|
}
|
|
48
57
|
|
|
58
|
+
/** ValueSet/$batch-validate-code: shared globals are url/valueSet. */
|
|
49
59
|
async handleValueSet(req, res) {
|
|
60
|
+
return this.processBatch(req, res, this.valueSetGlobalNames);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** CodeSystem/$batch-validate-code: shared globals are system/codeSystem. */
|
|
64
|
+
async handleCodeSystem(req, res) {
|
|
65
|
+
return this.processBatch(req, res, this.codeSystemGlobalNames);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Run a batch of $validate-code requests. Two passes: frontLoadBatch() pools all
|
|
70
|
+
* supplied resources into an unsealed cache first (see that method); then each
|
|
71
|
+
* `validation` entry is evaluated, inheriting the top-level globals it does not
|
|
72
|
+
* override and dispatched by shape to the ValueSet or CodeSystem validator.
|
|
73
|
+
*
|
|
74
|
+
* @param {express.Request} req
|
|
75
|
+
* @param {express.Response} res
|
|
76
|
+
* @param {Set<string>} globalNames - which top-level params are shared globals
|
|
77
|
+
*/
|
|
78
|
+
async processBatch(req, res, globalNames) {
|
|
50
79
|
try {
|
|
51
80
|
let params = req.body;
|
|
52
81
|
this.addHttpParams(req, params);
|
|
53
82
|
|
|
54
83
|
let globalParams = [];
|
|
55
84
|
for (const p of params.parameter) {
|
|
56
|
-
if (
|
|
85
|
+
if (globalNames.has(p.name)) {
|
|
57
86
|
globalParams.push(p);
|
|
58
87
|
}
|
|
59
88
|
}
|
|
60
89
|
|
|
90
|
+
// Pass 1: front-load every resource the batch supplies into the (unsealed)
|
|
91
|
+
// session cache before any entry is evaluated. When this happens, pass 2
|
|
92
|
+
// below drops per-entry tx-resource processing (they're already cached).
|
|
93
|
+
const frontLoaded = this.frontLoadBatch(params);
|
|
94
|
+
|
|
61
95
|
let output = [];
|
|
62
96
|
|
|
63
97
|
for (const p of params.parameter) {
|
|
@@ -65,12 +99,26 @@ class BatchValidateWorker extends TerminologyWorker {
|
|
|
65
99
|
let op = new Parameters();
|
|
66
100
|
op.jsonObj.parameter = [];
|
|
67
101
|
for (const gp of globalParams) {
|
|
102
|
+
if (gp.name == 'tx-resource') {
|
|
103
|
+
// Pass 2: when the batch was front-loaded into an unsealed cache, the
|
|
104
|
+
// tx-resources are already in the cache and every entry resolves them
|
|
105
|
+
// by reference - don't re-inject or re-process them per entry. Without
|
|
106
|
+
// front-loading (no cache, or a sealed cache) keep the original
|
|
107
|
+
// behaviour: the global tx-resources apply to every entry.
|
|
108
|
+
if (!frontLoaded) {
|
|
109
|
+
op.jsonObj.parameter.push(gp);
|
|
110
|
+
}
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
68
113
|
let exists = p.resource.parameter.find(pp => gp.name == pp.name);
|
|
69
|
-
if (
|
|
114
|
+
if (!exists) {
|
|
70
115
|
op.jsonObj.parameter.push(gp);
|
|
71
116
|
}
|
|
72
117
|
}
|
|
73
|
-
|
|
118
|
+
const entryParams = frontLoaded
|
|
119
|
+
? p.resource.parameter.filter(pp => pp.name !== 'tx-resource')
|
|
120
|
+
: p.resource.parameter;
|
|
121
|
+
op.jsonObj.parameter.push(...entryParams);
|
|
74
122
|
|
|
75
123
|
let worker = new ValidateWorker(this.opContext.copy(), this.log, this.provider, this.languages, this.i18n);
|
|
76
124
|
try {
|
|
@@ -100,11 +148,70 @@ class BatchValidateWorker extends TerminologyWorker {
|
|
|
100
148
|
} catch (error) {
|
|
101
149
|
this.log.error(error);
|
|
102
150
|
debugLog(error);
|
|
151
|
+
// A batch-level failure (e.g. an unknown cache-id from pass 1) applies to the
|
|
152
|
+
// whole batch. Preserve a coded Issue (like cache-id-unknown) so the client
|
|
153
|
+
// gets the same coded OperationOutcome + status it would on a single op.
|
|
154
|
+
if (error instanceof Issue) {
|
|
155
|
+
const oo = new OperationOutcome();
|
|
156
|
+
oo.addIssue(error);
|
|
157
|
+
return res.status(error.statusCode || 500).json(oo.jsonObj);
|
|
158
|
+
}
|
|
103
159
|
return res.status(error.statusCode || 500).json(this.operationOutcome(
|
|
104
160
|
'error', error.issueCode || 'exception', error.message));
|
|
105
161
|
}
|
|
106
162
|
}
|
|
107
163
|
|
|
164
|
+
/**
|
|
165
|
+
* Pass 1 of batch processing: front-load every resource the batch supplies
|
|
166
|
+
* (each `tx-resource`, plus each entry's primary `valueSet`/`codeSystem`) into
|
|
167
|
+
* the session cache before any entry is evaluated. This makes the batch
|
|
168
|
+
* order-independent - an entry may refer by url to a resource another entry
|
|
169
|
+
* supplied - and means a failing entry does not withhold the resources it
|
|
170
|
+
* carried, because population happens up front and as one step.
|
|
171
|
+
*
|
|
172
|
+
* Front-loading is only relevant for an *unsealed* cache: that is what grows, so
|
|
173
|
+
* that is how one entry's resources become visible to the others. A sealed cache
|
|
174
|
+
* does not grow (each entry stays self-contained), and with no cache there is
|
|
175
|
+
* nowhere to pool; in both cases this returns false and pass 2 keeps the original
|
|
176
|
+
* per-entry tx-resource handling.
|
|
177
|
+
*
|
|
178
|
+
* The unknown-cache-id check is done here, once, for the whole batch.
|
|
179
|
+
*
|
|
180
|
+
* @param {Object} params - the batch Parameters (req.body)
|
|
181
|
+
* @returns {boolean} true if resources were front-loaded into an unsealed cache
|
|
182
|
+
*/
|
|
183
|
+
frontLoadBatch(params) {
|
|
184
|
+
const cacheId = this.opContext ? this.opContext.cacheId : null;
|
|
185
|
+
const cache = this.opContext ? this.opContext.resourceCache : null;
|
|
186
|
+
if (!cacheId || !cache) {
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
if (!cache.has(cacheId)) {
|
|
190
|
+
throw new Issue('error', 'not-found', null, 'CACHE_ID_UNKNOWN',
|
|
191
|
+
this.i18n.translate('CACHE_ID_UNKNOWN', this.opContext.langs, [cacheId]),
|
|
192
|
+
'cache-id-unknown', 404);
|
|
193
|
+
}
|
|
194
|
+
if (cache.isSealed(cacheId)) {
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
// Flatten the top-level params and every entry's params into one list, then let
|
|
198
|
+
// collectSuppliedResources pick out the tx-resource + primary valueSet/codeSystem.
|
|
199
|
+
const allParams = [];
|
|
200
|
+
for (const p of params.parameter) {
|
|
201
|
+
if (p.name === 'validation' && p.resource && Array.isArray(p.resource.parameter)) {
|
|
202
|
+
allParams.push(...p.resource.parameter);
|
|
203
|
+
} else {
|
|
204
|
+
allParams.push(p);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
const { txResources, primaryResources } = this.collectSuppliedResources({ parameter: allParams });
|
|
208
|
+
const pool = txResources.concat(primaryResources);
|
|
209
|
+
if (pool.length > 0) {
|
|
210
|
+
cache.add(cacheId, pool);
|
|
211
|
+
}
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
|
|
108
215
|
/**
|
|
109
216
|
* Build an OperationOutcome
|
|
110
217
|
*/
|
|
@@ -124,17 +124,46 @@ class CacheControlWorker extends TerminologyWorker {
|
|
|
124
124
|
const { txResources, primaryResources } = this.collectSuppliedResources(params.jsonObj);
|
|
125
125
|
const resources = txResources.concat(primaryResources);
|
|
126
126
|
|
|
127
|
+
// `sealed` controls whether the cache may grow after creation. When sealed,
|
|
128
|
+
// the cache holds only the resources front-loaded here; when unsealed, later
|
|
129
|
+
// operations accumulate every resource they see into it (see
|
|
130
|
+
// setupAdditionalResources).
|
|
131
|
+
//
|
|
132
|
+
// NOTE: the protocol default is `true`, but the server default here is
|
|
133
|
+
// deliberately `false` during the transition: existing clients that rely on
|
|
134
|
+
// incremental population and do not yet send `sealed` must keep working.
|
|
135
|
+
// Flip this to default-true once all clients send an explicit value.
|
|
136
|
+
const sealed = this.readSealed(params.jsonObj);
|
|
137
|
+
|
|
127
138
|
const cacheId = crypto.randomUUID();
|
|
128
|
-
cache.set(cacheId, resources);
|
|
139
|
+
cache.set(cacheId, resources, sealed);
|
|
129
140
|
|
|
130
141
|
return res.status(200).json({
|
|
131
142
|
resourceType: 'Parameters',
|
|
132
143
|
parameter: [
|
|
133
|
-
{ name: 'cache-id', valueId: cacheId }
|
|
144
|
+
{ name: 'cache-id', valueId: cacheId },
|
|
145
|
+
{ name: 'sealed', valueBoolean: sealed }
|
|
134
146
|
]
|
|
135
147
|
});
|
|
136
148
|
}
|
|
137
149
|
|
|
150
|
+
/**
|
|
151
|
+
* Read the `sealed` boolean from the start request's Parameters.
|
|
152
|
+
*
|
|
153
|
+
* Server-side default is FALSE (transitional — see start()): a cache is only
|
|
154
|
+
* sealed if the client explicitly asks for it. Accepts a real JSON boolean or
|
|
155
|
+
* the string "true"/"false" for robustness across clients.
|
|
156
|
+
*
|
|
157
|
+
* @param {Object} params - Parameters resource (jsonObj)
|
|
158
|
+
* @returns {boolean}
|
|
159
|
+
*/
|
|
160
|
+
readSealed(params) {
|
|
161
|
+
const p = this.findParameter(params, 'sealed');
|
|
162
|
+
if (!p) return false;
|
|
163
|
+
const v = this.getParameterValue(p);
|
|
164
|
+
return v === true || v === 'true';
|
|
165
|
+
}
|
|
166
|
+
|
|
138
167
|
/**
|
|
139
168
|
* mode=end: release the cache named by the `${CACHE_ID_HEADER}` header so the
|
|
140
169
|
* server can reclaim it now rather than waiting for the idle timeout.
|