fhirsmith 0.10.1 → 0.11.1

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 (56) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/changelog-template.md +22 -0
  3. package/library/logger.js +8 -1
  4. package/library/package-manager.js +40 -2
  5. package/npmprojector/watcher.js +1 -1
  6. package/package.json +3 -2
  7. package/packages/packages.js +28 -15
  8. package/publisher/publisher.js +8 -8
  9. package/registry/api.js +92 -31
  10. package/registry/crawler.js +15 -1
  11. package/registry/model.js +158 -8
  12. package/registry/readme.md +5 -13
  13. package/registry/registry.js +87 -46
  14. package/registry/test-runner.js +1 -1
  15. package/server.js +8 -1
  16. package/stats.js +5 -1
  17. package/token/token.js +1 -1
  18. package/translations/Messages.properties +3 -1
  19. package/translations/rendering-phrases.properties +1227 -1218
  20. package/translations/rendering-phrases_ar.properties +1097 -1091
  21. package/translations/rendering-phrases_fr.properties +626 -624
  22. package/translations/rendering-phrases_nl.properties +976 -970
  23. package/translations/rendering-phrases_pt.properties +1026 -1020
  24. package/translations/rendering-phrases_ru.properties +1100 -1094
  25. package/tsconfig.eslint.json +9 -0
  26. package/tx/cs/cs-api.js +57 -4
  27. package/tx/cs/cs-cs.js +98 -25
  28. package/tx/cs/cs-hgvs.js +4 -0
  29. package/tx/cs/cs-lang.js +8 -0
  30. package/tx/cs/cs-mimetypes.js +4 -0
  31. package/tx/cs/cs-snomed.js +530 -75
  32. package/tx/cs/cs-uri.js +4 -0
  33. package/tx/data/snomed-subset.txt +40 -3
  34. package/tx/html/home-metrics.liquid +16 -1
  35. package/tx/importers/import-sct.module.js +18 -0
  36. package/tx/library/designations.js +3 -5
  37. package/tx/library/renderer.js +227 -30
  38. package/tx/library.js +5 -1
  39. package/tx/operation-context.js +131 -5
  40. package/tx/provider.js +38 -9
  41. package/tx/sct/ecl.js +30 -11
  42. package/tx/sct/expressions.js +28 -1
  43. package/tx/sct/structures.js +5 -5
  44. package/tx/tx-html.js +24 -15
  45. package/tx/tx.fhir.org.yml +1 -4
  46. package/tx/tx.js +18 -2
  47. package/tx/vs/vs-package.js +52 -0
  48. package/tx/workers/batch-validate.js +125 -18
  49. package/tx/workers/cache-control.js +31 -2
  50. package/tx/workers/expand.js +150 -76
  51. package/tx/workers/metadata.js +27 -6
  52. package/tx/workers/search.js +7 -1
  53. package/tx/workers/validate.js +30 -18
  54. package/tx/workers/worker.js +39 -7
  55. package/tx/xversion/xv-valueset.js +5 -2
  56. package/xig/xig.js +3 -3
@@ -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
  }
@@ -519,6 +556,15 @@ const MEMORY_LIMIT = readMemoryLimit();
519
556
  const MEMORY_FRACTION = 0.98;
520
557
  const MEMORY_THRESHOLD = MEMORY_LIMIT > 0 ? MEMORY_LIMIT * MEMORY_FRACTION : 0; // 90% of cgroup limit
521
558
  const CHECK_FREQUENCY = 100;
559
+ // How long an operation may compute without yielding the event loop (ms).
560
+ // Node runs all JS on one thread: while a long operation executes, no other
561
+ // request is serviced - awaiting in-memory promises does not help, because
562
+ // resolved promises only bounce through the microtask queue, which runs before
563
+ // I/O. checkAndYield() awaits a real setImmediate every YIELD_INTERVAL ms of
564
+ // compute, so pending I/O (new connections, /metadata, timers) runs between
565
+ // slices and one heavy $expand can no longer time out every other client.
566
+ const YIELD_INTERVAL = 25;
567
+ const { setImmediate: yieldToEventLoop } = require('timers/promises');
522
568
 
523
569
  class OperationContext {
524
570
  // Shared counter across all instances — only check RSS every CHECK_FREQUENCY calls
@@ -547,6 +593,24 @@ class OperationContext {
547
593
  // Shared by reference with copy()'d contexts so a sub-operation's
548
594
  // providers are cleaned up by the parent request's closeProviders().
549
595
  this._openProviders = [];
596
+ // Compute-time accounting for the deadline and for yielding. One object,
597
+ // shared by reference with copy()'d contexts, so an operation and its
598
+ // sub-operations (e.g. batch entries) draw down a single budget:
599
+ // - compute: ms of compute consumed in completed slices
600
+ // - sliceStart: when the current compute slice began
601
+ // - lastYield: when we last gave the event loop a turn
602
+ // - clientGone: set when the client disconnects before the response is sent
603
+ // The deadline is measured in COMPUTE time, not wall-clock: once operations
604
+ // time-share the event loop, wall-clock deadlines make concurrency itself
605
+ // cause aborts (N ops each take N x as long), which turns client retries
606
+ // into a failure amplifier. An operation that waited is not too costly;
607
+ // one that consumed its compute budget is.
608
+ this._clock = {
609
+ compute: 0,
610
+ sliceStart: this.startTime,
611
+ lastYield: this.startTime,
612
+ clientGone: false
613
+ };
550
614
 
551
615
  this.timeTracker.step('tx-op');
552
616
  }
@@ -579,6 +643,8 @@ class OperationContext {
579
643
  // Share the same provider-cleanup list so providers opened by the copy
580
644
  // are released when the parent operation ends.
581
645
  newContext._openProviders = this._openProviders;
646
+ // Share the compute clock: sub-operations spend the parent's budget.
647
+ newContext._clock = this._clock;
582
648
  return newContext;
583
649
  }
584
650
 
@@ -609,8 +675,10 @@ class OperationContext {
609
675
  }
610
676
  OperationContext._checkCounter = 0;
611
677
 
612
- // Time check
613
- const elapsed = performance.now() - this.startTime;
678
+ // Time check - against compute time consumed, not wall clock (see _clock in
679
+ // the constructor). For operations that never yield the two are identical,
680
+ // so this changes nothing for paths that don't use checkAndYield().
681
+ const elapsed = this.computeElapsed();
614
682
  if (elapsed > this.timeLimit) {
615
683
  const timeInSeconds = Math.round(this.timeLimit / 1000);
616
684
  this.log(`Operation took too long @ ${place} (${this.constructor.name})`);
@@ -637,6 +705,64 @@ class OperationContext {
637
705
  return false;
638
706
  }
639
707
 
708
+ /**
709
+ * Compute time this operation has consumed (ms): completed slices plus the
710
+ * slice currently executing. Equals wall-clock elapsed for operations that
711
+ * never yield.
712
+ * @returns {number}
713
+ */
714
+ computeElapsed() {
715
+ return this._clock.compute + (performance.now() - this._clock.sliceStart);
716
+ }
717
+
718
+ /**
719
+ * Mark that the client for this operation disconnected before the response
720
+ * was sent. The operation is aborted at its next yield point - there is no
721
+ * one left to send the result to. (Wired up by the request middleware from
722
+ * the response 'close' event.)
723
+ */
724
+ markClientGone() {
725
+ this._clock.clientGone = true;
726
+ }
727
+
728
+ /**
729
+ * deadCheck plus cooperative yielding: call this (awaited) from long-running
730
+ * loops in async code. Runs the normal deadCheck, and after every
731
+ * YIELD_INTERVAL ms of continuous compute awaits a real setImmediate so the
732
+ * event loop can service pending I/O - other requests keep getting answered
733
+ * while this operation works. On resuming from a yield, aborts if the client
734
+ * has disconnected in the meantime.
735
+ *
736
+ * Sync call sites keep using deadCheck(); the async loops that surround them
737
+ * yield on their behalf, which caps unbroken compute at one loop iteration.
738
+ *
739
+ * @param {string} place - Location identifier for debugging
740
+ */
741
+ async checkAndYield(place = 'unknown') {
742
+ this.deadCheck(place);
743
+ if (this.debugging) {
744
+ return;
745
+ }
746
+ const now = performance.now();
747
+ if (now - this._clock.lastYield >= YIELD_INTERVAL) {
748
+ // Close off this compute slice before suspending, so time spent waiting
749
+ // for our next turn is not charged against the deadline.
750
+ this._clock.compute += now - this._clock.sliceStart;
751
+ await yieldToEventLoop();
752
+ const resumed = performance.now();
753
+ this._clock.sliceStart = resumed;
754
+ this._clock.lastYield = resumed;
755
+ if (this._clock.clientGone) {
756
+ this.log(`Operation abandoned @ ${place}: client disconnected`);
757
+ const error = new Issue("error", "too-costly", null,
758
+ `Operation abandoned at ${place}: the client disconnected before the response was ready`);
759
+ error.abandoned = true;
760
+ error.diagnostics = this.diagnostics();
761
+ throw error;
762
+ }
763
+ }
764
+ }
765
+
640
766
  unSeeAll() {
641
767
  this.contexts = [];
642
768
  }
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
- const existing = this.codeSystems.get(cs.url);
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
- if (!existing || cs.isMoreRecent(existing)) {
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
- this.codeSystems.set(cs.vurl, cs);
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
- let cardinality = null;
640
-
641
- // Check for cardinality before {
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
- // Check for cardinality first - this must come before attribute name
667
- if (this.match(ECLTokenType.LBRACKET)) {
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
 
@@ -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: i === 0, // First description is considered preferred
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,
@@ -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
- let _fmt = req.query._format || req.query.format || req.body?._format;
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
  };
@@ -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:sct_test_20250814.cache
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/tx.js CHANGED
@@ -291,9 +291,15 @@ class TXModule {
291
291
  // Get Accept-Language header for language preferences
292
292
  const acceptLanguage = req.get('Accept-Language') || 'en';
293
293
 
294
- // Create operation context with language, ID, time limit, and caches
294
+ // Create operation context with language, ID, time limit, and caches.
295
+ // The time limit is a compute-time budget (see OperationContext._clock)
296
+ // and is configurable (config.operationTimeLimit, seconds). The default
297
+ // sits deliberately under common client timeouts (okhttp reads give up at
298
+ // 10-30s): if the server's limit is >= the client's, the client hangs up
299
+ // first and the carefully-built too-costly OperationOutcome is written to
300
+ // a socket nobody is reading.
295
301
  const opContext = new OperationContext(
296
- acceptLanguage, this.i18n, requestId, 30,
302
+ acceptLanguage, this.i18n, requestId, this.config.operationTimeLimit ?? 20,
297
303
  endpointInfo.resourceCache, endpointInfo.expansionCache
298
304
  );
299
305
  opContext.usageTracker = this.usageTracker;
@@ -325,6 +331,16 @@ class TXModule {
325
331
  res.on('finish', releaseProviders);
326
332
  res.on('close', releaseProviders);
327
333
 
334
+ // If the connection closes before the response was completed, the client
335
+ // has given up (timeout, cancelled build, dropped connection). Mark the
336
+ // operation so it aborts at its next yield point instead of computing a
337
+ // response nobody will read.
338
+ res.on('close', () => {
339
+ if (!res.writableEnded) {
340
+ opContext.markClientGone();
341
+ }
342
+ });
343
+
328
344
  // Add X-Request-Id header to response
329
345
  res.setHeader('X-Request-Id', requestId);
330
346
 
@@ -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
  }