fhirsmith 0.18.0 → 0.18.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CHANGELOG.md +72 -10
  2. package/ips/ips.js +251 -26
  3. package/ips/jurisdictions.js +65 -0
  4. package/ips/pipeline.js +67 -6
  5. package/ips/profile-editor.js +283 -4
  6. package/ips/readme.md +48 -5
  7. package/ips/render.js +317 -46
  8. package/ips/scripts.js +46 -0
  9. package/ips/store.js +55 -0
  10. package/library/daily-report.js +25 -15
  11. package/package.json +3 -3
  12. package/registry/api.js +179 -1
  13. package/registry/crawler.js +2 -0
  14. package/registry/documentation/api.md +68 -2
  15. package/registry/documentation/internals.md +4 -1
  16. package/registry/documentation/readme.md +3 -2
  17. package/registry/model.js +8 -0
  18. package/registry/openapi.yaml +84 -0
  19. package/registry/registry.js +53 -2
  20. package/translations/Messages.properties +1 -0
  21. package/tx/README.md +4 -0
  22. package/tx/closure/closure-store.js +14 -0
  23. package/tx/cm/cm-package.js +21 -15
  24. package/tx/cs/cs-api.js +3 -1
  25. package/tx/cs/cs-cs.js +59 -12
  26. package/tx/cs/cs-icf.js +47 -34
  27. package/tx/cs/cs-loinc.js +257 -3
  28. package/tx/cs/cs-snomed.js +544 -55
  29. package/tx/importers/csv-records.js +35 -0
  30. package/tx/importers/import-loinc.module.js +177 -41
  31. package/tx/importers/import-sct.module.js +131 -1
  32. package/tx/importers/subset-loinc.module.js +25 -6
  33. package/tx/library/codesystem.js +4 -1
  34. package/tx/library/designations.js +23 -30
  35. package/tx/library/tho-precedence.js +40 -0
  36. package/tx/openapi-operations.js +2 -1
  37. package/tx/provider.js +46 -29
  38. package/tx/sct/documentation.md +45 -21
  39. package/tx/sct/ecl.js +34 -11
  40. package/tx/sct/structures.js +15 -2
  41. package/tx/tx.fhir.org.yml +2 -2
  42. package/tx/vs/vs-package.js +17 -13
  43. package/tx/vs/vs-vsac.js +1 -1
  44. package/tx/workers/closure.js +20 -0
  45. package/tx/workers/expand.js +2 -2
  46. package/tx/workers/validate.js +10 -0
  47. package/utilities/make-sct-loinc-subset.js +250 -0
@@ -564,6 +564,19 @@ class Designations {
564
564
  return this._langsMatch(langList, cd.language, LangMatchType.LANG, defLang);
565
565
  }
566
566
 
567
+ /**
568
+ * Note, in the set of supplements to report as used, the supplement a designation came from (if
569
+ * it came from one). Designations from a supplement carry it as .supplement (see
570
+ * CodeSystemProvider._listSupplementDesignations); this used to look for .source, which is never
571
+ * set on a designation, so a supplement that only supplied the chosen display - a global language
572
+ * pack, typically - was never reported as used.
573
+ */
574
+ _reportSupplement(supplements, cd) {
575
+ if (supplements && cd && cd.supplement && cd.supplement.vurl) {
576
+ supplements.add(cd.supplement.vurl);
577
+ }
578
+ }
579
+
567
580
  /**
568
581
  * Find the preferred designation for given language preferences
569
582
  */
@@ -576,39 +589,29 @@ class Designations {
576
589
  // No language list, prefer base designations
577
590
  for (const cd of this.designations) {
578
591
  if (this._isPreferred(cd) && cd.isActive()) {
579
- if (supplements && cd.source) {
580
- supplements.add(cd.source);
581
- }
592
+ this._reportSupplement(supplements, cd);
582
593
  return cd;
583
594
  }
584
595
  }
585
596
  for (const cd of this.designations) {
586
597
  if (this.isDisplay(cd) && cd.isActive()) {
587
- if (supplements && cd.source) {
588
- supplements.add(cd.source);
589
- }
598
+ this._reportSupplement(supplements, cd);
590
599
  return cd;
591
600
  }
592
601
  }
593
602
  for (const cd of this.designations) {
594
603
  if (this.isDisplay(cd)) {
595
- if (supplements && cd.source) {
596
- supplements.add(cd.source);
597
- }
604
+ this._reportSupplement(supplements, cd);
598
605
  return cd;
599
606
  }
600
607
  }
601
608
  for (const cd of this.designations) {
602
609
  if (this._isPreferred(cd)) {
603
- if (supplements && cd.source) {
604
- supplements.add(cd.source);
605
- }
610
+ this._reportSupplement(supplements, cd);
606
611
  return cd;
607
612
  }
608
613
  }
609
- if (supplements && this.designations[0].source) {
610
- supplements.add(this.designations[0].source);
611
- }
614
+ this._reportSupplement(supplements, this.designations[0]);
612
615
  return this.designations[0];
613
616
  }
614
617
 
@@ -628,25 +631,19 @@ class Designations {
628
631
  // because the FSN was emitted first.
629
632
  for (const cd of this.designations) {
630
633
  if (this._langMatches(lang, cd.language, matchType) && this._isPreferred(cd) && (!activeOnly || cd.isActive())) {
631
- if (supplements && cd.source) {
632
- supplements.add(cd.source);
633
- }
634
+ this._reportSupplement(supplements, cd);
634
635
  return cd;
635
636
  }
636
637
  }
637
638
  for (const cd of this.designations) {
638
639
  if (this._langMatches(lang, cd.language, matchType) && this.isDisplay(cd) && (!activeOnly || cd.isActive())) {
639
- if (supplements && cd.source) {
640
- supplements.add(cd.source);
641
- }
640
+ this._reportSupplement(supplements, cd);
642
641
  return cd;
643
642
  }
644
643
  }
645
644
  for (const cd of this.designations) {
646
645
  if (this._langMatches(lang, cd.language, matchType) && (!activeOnly || cd.isActive())) {
647
- if (supplements && cd.source) {
648
- supplements.add(cd.source);
649
- }
646
+ this._reportSupplement(supplements, cd);
650
647
  return cd;
651
648
  }
652
649
  }
@@ -655,14 +652,10 @@ class Designations {
655
652
  }
656
653
  for (const cd of this.designations) {
657
654
  if (!cd.language && this.isDisplay(cd) && cd.isActive()) {
658
- if (supplements && cd.source) {
659
- supplements.add(cd.source);
660
- }
655
+ this._reportSupplement(supplements, cd);
661
656
  return cd;
662
657
  } if (!cd.language && this.isDisplay(cd)) {
663
- if (supplements && cd.source) {
664
- supplements.add(cd.source);
665
- }
658
+ this._reportSupplement(supplements, cd);
666
659
  return cd;
667
660
  }
668
661
  }
@@ -0,0 +1,40 @@
1
+ //
2
+ // THO precedence for value sets and concept maps (see Provider.applyTHOPrecedence).
3
+ //
4
+ // Where hl7.terminology provides a resource with the same url as one in the FHIR core
5
+ // package, THO's copy is the one used - versions were reset when content moved to THO, so
6
+ // version order can't decide. But the core copy is still what a reference that names its
7
+ // version (url|5.0.0) means, so it stays available to that reference, and only to it.
8
+ //
9
+
10
+ const { VersionUtilities } = require('../../library/version-utilities');
11
+
12
+ /**
13
+ * Whether a package provider should keep a map key for a resource that THO also provides.
14
+ * Only the keys that name a version are kept - url|version, and url|major.minor for a
15
+ * semver version - and only where THO doesn't provide that version itself. The bare url
16
+ * (the default for a reference with no version) and the resource's id are not kept.
17
+ *
18
+ * @param {string} key - the map key
19
+ * @param {{url: string, version: string}} resource - the core package's resource
20
+ * @param {Set<string>} thoVersions - the versions of the url that hl7.terminology provides
21
+ * @returns {boolean}
22
+ */
23
+ function keepForPinnedReference(key, resource, thoVersions) {
24
+ const version = resource.version;
25
+ if (!version) {
26
+ return false;
27
+ }
28
+ if (key === resource.url + '|' + version) {
29
+ return !thoVersions.has(version);
30
+ }
31
+ if (VersionUtilities.isSemVer(version)) {
32
+ const mm = VersionUtilities.getMajMin(version);
33
+ if (mm && key === resource.url + '|' + mm) {
34
+ return ![...thoVersions].some(v => v && VersionUtilities.isSemVer(v) && VersionUtilities.getMajMin(v) === mm);
35
+ }
36
+ }
37
+ return false;
38
+ }
39
+
40
+ module.exports = { keepForPinnedReference };
@@ -332,7 +332,8 @@ const OPERATIONS = [
332
332
  p('name', 'string', '1', 'The closure table', { min: 1 }),
333
333
  p('concept', 'Coding', '*', 'A concept to add'),
334
334
  p('version', 'string', '1', 'Return the changes since this version of the table, instead of adding concepts'),
335
- p('reset', 'boolean', '1', 'Start the table again')
335
+ p('reset', 'boolean', '1', 'Start the table again'),
336
+ p('dispose', 'boolean', '1', 'The client has finished with the table, and the server can delete it (not in the specification)')
336
337
  ],
337
338
  returns: 'ConceptMap',
338
339
  returnsDoc: 'The new relationships'
package/tx/provider.js CHANGED
@@ -286,10 +286,13 @@ class Provider {
286
286
  }
287
287
  }
288
288
 
289
+ // Ask each code system version once: an implicit map can belong to one version
290
+ // only (a SNOMED CT edition's maps are named by its edition URI)
289
291
  let uris = new Set();
290
292
  for (let csp of this.codeSystemFactories.values()) {
291
- if (!uris.has(csp.system())) {
292
- uris.add(csp.system());
293
+ const key = csp.system() + '|' + csp.version();
294
+ if (!uris.has(key)) {
295
+ uris.add(key);
293
296
  let cm = await csp.findImplicitConceptMap(url, version);
294
297
  if (cm) {
295
298
  return cm;
@@ -343,16 +346,19 @@ class Provider {
343
346
  // version (every SNOMED edition, say) is asked, since whether a map exists and what
344
347
  // it says can depend on which version the source coding is in. The factory decides
345
348
  // from sourceVersion whether it is the one being asked about.
349
+ // The same goes for the target system: a map from another system into one SNOMED
350
+ // edition (LOINC codes to the concepts that carry them, say) belongs to that edition.
351
+ const targetBase = targetSystem.split('|')[0];
346
352
  let uris = new Set();
347
353
  let asked = new Set(); // a factory is registered under several keys (system, system|version...)
348
354
  for (let csp of this.codeSystemFactories.values()) {
349
355
  if (asked.has(csp)) {
350
356
  continue;
351
357
  }
352
- if (csp.system() === sourceSystem || !uris.has(csp.system())) {
358
+ if (csp.system() === sourceSystem || csp.system() === targetBase || !uris.has(csp.system())) {
353
359
  uris.add(csp.system());
354
360
  asked.add(csp);
355
- await csp.findImplicitConceptMaps(conceptMaps, sourceSystem, sourceVersion, targetSystem);
361
+ await csp.findImplicitConceptMaps(conceptMaps, sourceSystem, sourceVersion, targetSystem, sourceCode);
356
362
  }
357
363
  }
358
364
  }
@@ -626,27 +632,20 @@ class Provider {
626
632
  * moved from the FHIR core packages to terminology.hl7.org has been QA'd there since, and
627
633
  * its versions went backwards (coverage-selfpay is 4.0.1 in hl7.fhir.r4.core but 1.0.1 in
628
634
  * hl7.terminology), so version precedence can't decide between them. Instead, a code system
629
- * from an hl7.terminology package replaces every code system with the same url from a FHIR
630
- * core package - none of the core versions stay available.
635
+ * from an hl7.terminology package is always the default for its url - what a reference with
636
+ * no version gets - whatever versions the FHIR core packages have.
631
637
  *
632
- * Except: hl7.fhir.r4.core has the v2 tables 0006, 0360 and 0391 in two versions each,
633
- * with the version in the url (http://terminology.hl7.org/CodeSystem/v2-0006|2.1 - see
634
- * urlHadVersion in loadNpm). hl7.terminology doesn't publish those versions, so they stay
635
- * available by url|version; THO's code system is the default for the url.
638
+ * The core versions stay available by url|version, where THO doesn't provide that version
639
+ * itself: a reference that names its version (url|5.0.0) means the core one, and there's
640
+ * nothing else it can mean. They are never the default while THO provides the url (see also
641
+ * deleteCodeSystem).
636
642
  */
637
643
  addCodeSystem(cs) {
638
644
  if (Provider.#isTHOPackage(cs.sourcePackage)) {
639
- for (const [key, t] of [...this.codeSystems]) {
640
- if (t.url === cs.url && VersionUtilities.isCorePackage(t.sourcePackage)
641
- && (key === t.url || !t.urlHadVersion)) {
642
- this.codeSystems.delete(key);
643
- }
644
- }
645
- } else if (VersionUtilities.isCorePackage(cs.sourcePackage) && !cs.urlHadVersion) {
646
- // THO's code system is always the default for its url
645
+ // THO's code system takes over the default from a core one; the core versions stay
647
646
  const t = this.codeSystems.get(cs.url);
648
- if (t && Provider.#isTHOPackage(t.sourcePackage)) {
649
- return;
647
+ if (t && t.url === cs.url && VersionUtilities.isCorePackage(t.sourcePackage)) {
648
+ this.codeSystems.delete(cs.url);
650
649
  }
651
650
  }
652
651
  const existing = this.codeSystems.get(cs.url);
@@ -671,45 +670,63 @@ class Provider {
671
670
  * THO precedence for value sets and concept maps, as for code systems (see addCodeSystem):
672
671
  * once the library's providers have been added after this endpoint's core package, the core
673
672
  * package's value sets and concept maps whose urls an hl7.terminology package also provides
674
- * are left out. (No core value set or concept map has the version in its url.)
673
+ * are only available to a reference that names their version (and THO doesn't provide that
674
+ * version itself). They are never what a reference with no version gets, and they aren't
675
+ * read by id, found by search, or used for translation unless asked for.
675
676
  */
676
677
  applyTHOPrecedence() {
677
- const thoValueSets = new Set();
678
+ const thoValueSets = new Map();
678
679
  for (const vp of this.valueSetProviders) {
679
680
  if (vp instanceof PackageValueSetProvider && Provider.#isTHOPackage(vp.sourcePackage())) {
680
681
  for (const vs of vp.valueSetMap.values()) {
681
- thoValueSets.add(vs.url);
682
+ Provider.#addVersion(thoValueSets, vs);
682
683
  }
683
684
  }
684
685
  }
685
- const thoConceptMaps = new Set();
686
+ const thoConceptMaps = new Map();
686
687
  for (const cmp of this.conceptMapProviders) {
687
688
  if (cmp instanceof PackageConceptMapProvider && Provider.#isTHOPackage(cmp.sourcePackage())) {
688
689
  for (const cm of cmp.conceptMapMap.values()) {
689
- thoConceptMaps.add(cm.url);
690
+ Provider.#addVersion(thoConceptMaps, cm);
690
691
  }
691
692
  }
692
693
  }
693
694
  for (const vp of this.valueSetProviders) {
694
695
  if (vp instanceof PackageValueSetProvider && VersionUtilities.isCorePackage(vp.sourcePackage())) {
695
- vp.excludeUrls(thoValueSets);
696
+ vp.keepPinnedOnly(thoValueSets);
696
697
  }
697
698
  }
698
699
  for (const cmp of this.conceptMapProviders) {
699
700
  if (cmp instanceof PackageConceptMapProvider && VersionUtilities.isCorePackage(cmp.sourcePackage())) {
700
- cmp.excludeUrls(thoConceptMaps);
701
+ cmp.keepPinnedOnly(thoConceptMaps);
701
702
  }
702
703
  }
703
704
  }
704
705
 
706
+ static #addVersion(map, resource) {
707
+ if (!resource || !resource.url) {
708
+ return;
709
+ }
710
+ if (!map.has(resource.url)) {
711
+ map.set(resource.url, new Set());
712
+ }
713
+ if (resource.version) {
714
+ map.get(resource.url).add(resource.version);
715
+ }
716
+ }
717
+
705
718
  deleteCodeSystem(cs) {
706
719
  this.codeSystems.delete(cs.vurl);
707
720
  this.codeSystems.delete(cs.url);
708
721
  // If other versions of the SAME url survive, re-point the unversioned [url]
709
722
  // entry at the most-recent surviving version. Otherwise leave it deleted.
723
+ // THO precedence (see addCodeSystem): while a THO version survives, a core one
724
+ // can't become the default, however recent its (reset) version looks
725
+ const survivors = [...new Set(this.codeSystems.values())].filter(t => t.url === cs.url);
726
+ const tho = survivors.filter(t => Provider.#isTHOPackage(t.sourcePackage));
710
727
  let existing = null;
711
- for (let t of this.codeSystems.values()) {
712
- if (t.url === cs.url && (!existing || t.isMoreRecent(existing))) {
728
+ for (let t of (tho.length > 0 ? tho : survivors)) {
729
+ if (!existing || t.isMoreRecent(existing)) {
713
730
  existing = t;
714
731
  }
715
732
  }
@@ -35,14 +35,20 @@ that the server does not use. `filterECL` is the one that matters.
35
35
  reference set's own fields (concept, integer and string typed from the RF2
36
36
  file name). Since inactive member rows are dropped at import, `active = false`
37
37
  never matches.
38
- - `AND` / `OR` / `MINUS` (and `,`), with the ECL 6.4 rule that conjunction and
38
+ - Refinements: `=` and `!=` against an expression value (`!=` counts the
39
+ occurrences whose value is outside the set, so `A != X` needs at least one
40
+ A that is not X, and `[0..0] A != X` means every A is X); reversed
41
+ attributes (`R A = X`: the concept is the value of an A on something in X,
42
+ evaluated over the concept's inbound relationships); attribute names that
43
+ are a concept, `*`, or any sub expression (`<< 762705008 = ...`); attribute
44
+ sets joined by `AND`, `OR` or `,`, with brackets; attribute groups,
45
+ including `AND`/`OR` inside and between groups; attribute and group
46
+ cardinality, including `[0..0]`. Cardinality counts occurrences per role
47
+ group, per the SNOMED Computable Languages Group ruling (see
48
+ `_countAttributeMatches`).
49
+ - `AND` / `OR` / `MINUS`, and `,` as a conjunction at the expression level as
50
+ well as in refinements, with the ECL 6.4 rule that conjunction and
39
51
  disjunction cannot be mixed without brackets.
40
- - Refinements: `=` against an expression value; attribute names that are a
41
- concept, `*`, or any sub expression (`<< 762705008 = ...`); attribute sets
42
- joined by `AND`, `OR` or `,`, with brackets; attribute groups, including
43
- `AND`/`OR` inside and between groups; attribute and group cardinality,
44
- including `[0..0]`. Cardinality counts occurrences per role group, per the
45
- SNOMED Computable Languages Group ruling (see `_countAttributeMatches`).
46
52
  - Dotted expressions (`< X . 363698007`, `. << 363698007`, `. (A OR B)`).
47
53
  - `{{ C ... }}` concept filters on `active`, `definitionStatus`,
48
54
  `definitionStatusId`, `moduleId` and `effectiveTime`.
@@ -56,26 +62,37 @@ that the server does not use. `filterECL` is the one that matters.
56
62
  must be satisfied by a single description; separate blocks may be satisfied
57
63
  by different descriptions. Only active descriptions are considered unless
58
64
  the block has an `active` filter.
65
+ - Concrete values in refinements - numeric (`= #5`, `>= #1.5`, `!= #-1`),
66
+ string (`= "x"`, `= match:"x"`, a set) and boolean (`= true`) - against the
67
+ concrete value relationships, which the importer reads from
68
+ `sct2_RelationshipConcreteValues_*` into cache version 19. Numbers compare
69
+ as numbers (`#5` matches `#5.0`); strings use the same match / wild
70
+ semantics as description terms; a value of another kind never matches.
71
+ An edition imported before version 19 has no concrete values and reports
72
+ such a refinement as unsupported rather than answering with nothing.
73
+ - History supplements: `{{ + HISTORY }}` (= MAX), `HISTORY-MIN` (SAME AS),
74
+ `HISTORY-MOD` (SAME AS, WAS A, REPLACED BY, PARTIALLY EQUIVALENT TO),
75
+ `HISTORY-MAX` (every reference set under 900000000000522004 |Historical
76
+ association|), `HISTORY (*)` and `HISTORY (expression)`: the inactive
77
+ concepts that the selected association reference sets associate with a
78
+ concept in the result are added to it. A reference set the edition does not
79
+ carry contributes nothing. Whether a bare `HISTORY` means MAX is taken from
80
+ Snow Owl's reading of the specification - to be confirmed.
81
+ - Alternate identifiers (`LOINC#14394-1`, `"LOINC#14394-1"`): the scheme
82
+ alias names an identifier scheme (`IDENTIFIER_SCHEMES` in `cs-snomed.js`:
83
+ LOINC = 30051010000102, from the LOINC Extension's `sct2_Identifier` file,
84
+ cache version 18+), the code resolves to the concept(s) carrying it, and
85
+ hierarchy operators apply to them as to any set. An unknown scheme or a
86
+ code nobody carries is `INVALID_ECL`.
59
87
  - Comments (`/* ... */`), terms (`|...|`, not checked against descriptions).
60
88
 
61
89
  ### Parsed but reported as unsupported
62
90
 
63
- - Reverse attributes (`R 363698007 = ...`).
64
- - `!=` against an expression value in a refinement.
65
- - Concrete values in refinements - numeric (`= #5`, `>= #1.5`), string
66
- (`= "x"`, `= match:"x"`), boolean (`= true`). The importer does not read
67
- `sct2_RelationshipConcreteValues_*`, so no concrete values are in the cache
68
- to compare against. Supporting them means extending the importer and the
69
- relationship record, and rebuilding every cache.
70
- - Alternate identifiers (`"ICD10#A01.1"`, `SCTID#123`). No alternate
71
- identifier reference sets are loaded.
72
91
  - Reference set field selection on member-of (`^ [targetComponentId] refset`,
73
92
  `^ [referencedComponentId, targetComponentId] refset`). Deliberate: the
74
93
  selected fields, and the referenced components of many reference sets,
75
94
  are descriptions or other non-concept components, which an expansion has
76
95
  no way to represent.
77
- - History supplements (`{{ + HISTORY-MIN / MOD / MAX }}`). The association
78
- reference sets they draw on are loaded, so this is implementable.
79
96
 
80
97
  ### Not parsed
81
98
 
@@ -99,7 +116,13 @@ that the server does not use. `filterECL` is the one that matters.
99
116
  - `^` on a concept that is not a reference set yields an empty result rather
100
117
  than an error (Snowstorm's behaviour; agreed in the tx-ecosystem suite).
101
118
  `^` on a reference set whose members are descriptions (a language reference
102
- set) is reported as unsupported - there are no concepts to return.
119
+ set) is reported as unsupported when that reference set is named explicitly
120
+ - there are no concepts to return. Inside a computed operand (`^ *`,
121
+ `^ (< 900000000000455006)`) such a reference set contributes nothing and
122
+ the rest of the operand still counts.
123
+ - An attribute name without a comparison (`< X : 363698007`, or `(< X : A = v
124
+ OR B)` where the OR binds inside the refinement) is a syntax error, as the
125
+ grammar requires.
103
126
  - The ECL result for a value set filter is cached per (value set, code system
104
127
  version), so the first expansion of a large constraint is slow and later ones
105
128
  are not.
@@ -181,8 +204,9 @@ role group pairing.
181
204
 
182
205
  - Stated relationships and the OWL axiom reference sets. Everything is driven
183
206
  by the distributed inferred relationships; there is no classification.
184
- - Concrete value relationships (see ECL above).
185
- - Alternate identifier reference sets.
207
+ - Concrete value relationships are read into cache version 19 (October 2026);
208
+ caches built before that have none, and a concrete-value ECL refinement on
209
+ them is reported as unsupported.
186
210
  - Inactive reference set member rows (only active rows are stored).
187
211
  - Module ids and definition status on concepts are recorded by the current
188
212
  importer; caches built before mid-2026 have neither (every concept looks
package/tx/sct/ecl.js CHANGED
@@ -538,8 +538,11 @@ class ECLParser {
538
538
  // this compound sequence and reject a mix (e.g. "<< A OR << B AND << C").
539
539
  let compoundOp = null;
540
540
 
541
- while (this.match(ECLTokenType.AND, ECLTokenType.OR, ECLTokenType.MINUS)) {
542
- const operator = this.current;
541
+ // "," is a conjunction in the ECL grammar (conjunction = "AND" / ","), at
542
+ // this level as well as in refinements; a comma that reaches here was not
543
+ // claimed by a refinement.
544
+ while (this.match(ECLTokenType.AND, ECLTokenType.OR, ECLTokenType.MINUS, ECLTokenType.COMMA)) {
545
+ const operator = this.match(ECLTokenType.COMMA) ? { ...this.current, type: ECLTokenType.AND } : this.current;
543
546
  if (compoundOp !== null && operator.type !== compoundOp) {
544
547
  throw new Error(
545
548
  'Ambiguous ECL expression: conjunction, disjunction and exclusion ' +
@@ -726,18 +729,30 @@ class ECLParser {
726
729
  return { type: ECLNodeType.FILTER_CONSTRAINT, kind, filters };
727
730
  }
728
731
 
729
- if (kind !== 'M') {
730
- // Skip to the closing }} keeping the raw text for diagnostics
731
- const raw = [];
732
- while (!this.match(ECLTokenType.RFILTER)) {
733
- if (this.match(ECLTokenType.EOF)) {
734
- this.error('Unterminated filter constraint - missing }}');
732
+ if (kind === '+') {
733
+ // historySupplement = "{{" "+" "HISTORY" [ "-MIN" / "-MOD" / "-MAX" / "(" (expressionConstraint / "*") ")" ] "}}"
734
+ if (!this.match(ECLTokenType.SCTID) || !/^HISTORY(-MIN|-MOD|-MAX)?$/i.test(this.current.value)) {
735
+ this.error('Expected HISTORY, HISTORY-MIN, HISTORY-MOD or HISTORY-MAX after {{ +');
736
+ }
737
+ const word = this.current.value.toUpperCase();
738
+ this.advance();
739
+ const node = { type: ECLNodeType.FILTER_CONSTRAINT, kind, profile: word === 'HISTORY' ? 'MAX' : word.substring(8) };
740
+ if (this.match(ECLTokenType.LPAREN)) {
741
+ if (node.profile !== 'MAX' || word !== 'HISTORY') {
742
+ this.error('A history profile (HISTORY-MIN/MOD/MAX) cannot also name reference sets');
735
743
  }
736
- raw.push(this.current.value);
737
744
  this.advance();
745
+ if (this.match(ECLTokenType.WILDCARD)) {
746
+ this.advance();
747
+ node.profile = 'MAX';
748
+ } else {
749
+ node.profile = 'EXPLICIT';
750
+ node.refsets = this.parseExpressionConstraint();
751
+ }
752
+ this.expect(ECLTokenType.RPAREN);
738
753
  }
739
- this.advance(); // }}
740
- return { type: ECLNodeType.FILTER_CONSTRAINT, kind, raw: raw.join(' ') };
754
+ this.expect(ECLTokenType.RFILTER);
755
+ return node;
741
756
  }
742
757
 
743
758
  const filters = [this.parseMemberFilter()];
@@ -1430,6 +1445,14 @@ class ECLParser {
1430
1445
  };
1431
1446
  }
1432
1447
 
1448
+ if (!comparison) {
1449
+ // eclAttribute = [cardinality] [reverseFlag] eclAttributeName comparison - a
1450
+ // bare name is a syntax error. This is what "(A : B = C OR D)" trips on:
1451
+ // the OR binds inside the refinement, so D is an attribute name without a
1452
+ // comparison.
1453
+ this.error('Expected a comparison operator (=, !=, <, <=, >, >=) after the attribute name');
1454
+ }
1455
+
1433
1456
  return {
1434
1457
  type: ECLNodeType.ATTRIBUTE,
1435
1458
  cardinality,
@@ -1358,7 +1358,7 @@ class SnomedFileReader {
1358
1358
  }
1359
1359
 
1360
1360
  // Determine settings based on cache version string (matching Pascal logic)
1361
- if (result.cacheVersion === '17') { // SNOMED_CACHE_VERSION_CURRENT
1361
+ if (result.cacheVersion === '17' || result.cacheVersion === '18' || result.cacheVersion === '19') { // 18 = 17 + alternate identifiers; 19 = 18 + concrete values
1362
1362
  result.hasLangs = true;
1363
1363
  } else if (result.cacheVersion === '16') { // Assuming this is UTF8 version for now
1364
1364
  result.isUTF16 = false;
@@ -1379,9 +1379,22 @@ class SnomedFileReader {
1379
1379
  result.activeRoots.push(this.readUInt64());
1380
1380
  }
1381
1381
 
1382
- // Read default language LAST
1382
+ // Read default language
1383
1383
  result.defaultLanguage = this.readInteger();
1384
1384
 
1385
+ // Alternate identifiers (version 18): [[scheme, module, code, conceptId], ...]
1386
+ result.identifiers = [];
1387
+ if (result.cacheVersion === '18' || result.cacheVersion === '19') {
1388
+ result.identifiers = JSON.parse(this.readBytes().toString('utf8'));
1389
+ }
1390
+ // Concrete value relationships (version 19): [[sourceId, typeId, group, value], ...].
1391
+ // null (not []) when the cache predates them, so the server can tell "none" from
1392
+ // "not loaded".
1393
+ result.concreteValues = null;
1394
+ if (result.cacheVersion === '19') {
1395
+ result.concreteValues = JSON.parse(this.readBytes().toString('utf8'));
1396
+ }
1397
+
1385
1398
  return result;
1386
1399
  }
1387
1400
  }
@@ -20,7 +20,7 @@ sources:
20
20
  - internal:hgvs
21
21
  - ucum:tx/data/ucum-essence.xml
22
22
  - loinc:loinc-2.77-a.db
23
- - loinc!:Loinc_2.82.db
23
+ - loinc!:Loinc_2.83.db
24
24
  - rxnorm:rxnorm_03022026.db
25
25
  - ndc:ndc-20211101.db
26
26
  - unii:unii_20240622.db
@@ -32,7 +32,7 @@ sources:
32
32
  - snomed:sct_us_20230301.cache
33
33
  - snomed:sct_us_20240301.cache
34
34
  - snomed:sct_us_20250901.cache
35
- - snomed:sct_test_20250909c.cache
35
+ - snomed:sct_test_20250909e.cache
36
36
  - cpt:CodeSystem-cpt.db|cpt-2023-fragment-0.1.db
37
37
  - omop:omop_v20250227.db
38
38
  - npm:hl7.terminology=tho
@@ -8,6 +8,7 @@ const { checkContained } = require('../library/canonical-resource');
8
8
  const { VersionUtilities } = require('../../library/version-utilities');
9
9
  const {validateParameter} = require("../../library/utilities");
10
10
  const { applyIdPrefix, stripIdPrefix } = require('../library/resource-ids');
11
+ const { keepForPinnedReference } = require('../library/tho-precedence');
11
12
 
12
13
  /**
13
14
  * Package-based ValueSet provider using shared database layer
@@ -267,7 +268,7 @@ class PackageValueSetProvider extends AbstractValueSetProvider {
267
268
 
268
269
  // Only process each ValueSet once (use URL to deduplicate)
269
270
  const vsUrl = json.url;
270
- if (seen.has(vsUrl)) {
271
+ if (seen.has(vsUrl) || this.#excludedUrls.has(vsUrl)) {
271
272
  continue;
272
273
  }
273
274
 
@@ -460,35 +461,38 @@ class PackageValueSetProvider extends AbstractValueSetProvider {
460
461
  return this.#excludedUrls.size === 0 ? urls : urls.filter(url => !this.#excludedUrls.has(url));
461
462
  }
462
463
 
463
- // the value sets left out by excludeUrls - by url, and by their own (unprefixed) id
464
+ // the value sets left out by keepPinnedOnly - by url, and by their own (unprefixed) id
464
465
  #excludedUrls = new Set();
465
466
  #excludedIds = new Set();
466
467
 
467
468
  /**
468
- * Leave out the value sets with any of these urls: they can't be fetched, and they aren't
469
- * found by search. The FHIR core package's value sets are left out where hl7.terminology
470
- * provides the same url (see Provider.applyTHOPrecedence). Must be called once the
471
- * provider is initialized - the database is shared, so it isn't changed.
469
+ * The value sets with these urls are hl7.terminology's to provide (see
470
+ * Provider.applyTHOPrecedence): from this (core) package they are only available to a
471
+ * reference that names their version, where THO doesn't provide that version itself. They
472
+ * aren't the default for their url, can't be read by id, aren't found by search or listed.
473
+ * Must be called once the provider is initialized - the database is shared, so it isn't
474
+ * changed.
472
475
  *
473
- * @param {Set<string>} urls
476
+ * @param {Map<string, Set<string>>} thoVersions - url -> the versions hl7.terminology provides
474
477
  */
475
- excludeUrls(urls) {
476
- const excluded = new Set();
478
+ keepPinnedOnly(thoVersions) {
479
+ const affected = new Set();
477
480
  for (const vs of this.valueSetMap.values()) {
478
- if (vs && urls.has(vs.url)) {
479
- excluded.add(vs);
481
+ if (vs && thoVersions.has(vs.url)) {
482
+ affected.add(vs);
480
483
  }
481
484
  }
482
485
  for (const [key, vs] of [...this.valueSetMap]) {
483
- if (excluded.has(vs)) {
486
+ if (affected.has(vs) && !keepForPinnedReference(key, vs, thoVersions.get(vs.url))) {
484
487
  this.valueSetMap.delete(key);
485
488
  }
486
489
  }
487
- for (const vs of excluded) {
490
+ for (const vs of affected) {
488
491
  this.#excludedUrls.add(vs.url);
489
492
  this.#excludedIds.add(stripIdPrefix(vs.id, this.spaceId) ?? vs.id);
490
493
  }
491
494
  }
495
+
492
496
  }
493
497
 
494
498
  module.exports = {
package/tx/vs/vs-vsac.js CHANGED
@@ -42,7 +42,7 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
42
42
  this.apiKey = config.apiKey;
43
43
  // Optional operator password for the on-demand resync form (see info()).
44
44
  this.resyncPassword = config.resyncPassword || null;
45
- this.cacheFolder = folders.ensureFilePath("terminology-cache/vsac");
45
+ this.cacheFolder = folders.ensureFolder("terminology-cache", "vsac");
46
46
  // https: every request carries the API key. (VSAC redirects http to https, but only after
47
47
  // the key has been sent in the clear)
48
48
  this.baseUrl = (config.baseUrl || 'https://cts.nlm.nih.gov/fhir').replace(/\/+$/, '');