fhirsmith 0.9.6 → 0.10.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.
Files changed (49) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/library/folder-content-loader.js +91 -0
  3. package/npmprojector/npmprojector.js +2 -6
  4. package/package.json +1 -1
  5. package/packages/package-crawler.js +123 -6
  6. package/packages/packages.js +104 -0
  7. package/publisher/publisher.js +290 -12
  8. package/registry/crawler.js +85 -6
  9. package/registry/registry.js +24 -7
  10. package/server.js +11 -2
  11. package/stats.js +26 -18
  12. package/translations/Messages.properties +2 -1
  13. package/tx/cs/cs-cs.js +8 -0
  14. package/tx/cs/cs-loinc.js +1 -0
  15. package/tx/cs/cs-provider-list.js +2 -1
  16. package/tx/cs/cs-snomed.js +142 -59
  17. package/tx/data/snomed-testing.cache +0 -0
  18. package/tx/html/home-metrics.liquid +10 -10
  19. package/tx/library/canonical-resource.js +4 -2
  20. package/tx/library/codesystem.js +31 -0
  21. package/tx/library/conceptmap.js +24 -0
  22. package/tx/library/designations.js +27 -20
  23. package/tx/library/renderer.js +303 -22
  24. package/tx/library/ucum-types.js +4 -1
  25. package/tx/library/valueset.js +46 -0
  26. package/tx/library.js +65 -21
  27. package/tx/operation-context.js +122 -27
  28. package/tx/params.js +36 -8
  29. package/tx/provider.js +6 -3
  30. package/tx/tx-html.js +34 -0
  31. package/tx/tx.js +92 -30
  32. package/tx/vs/vs-vsac.js +157 -9
  33. package/tx/workers/cache-control.js +186 -0
  34. package/tx/workers/{related.js → compare.js} +27 -27
  35. package/tx/workers/expand.js +100 -96
  36. package/tx/workers/lookup.js +6 -0
  37. package/tx/workers/metadata.js +11 -6
  38. package/tx/workers/read.js +1 -1
  39. package/tx/workers/translate.js +20 -29
  40. package/tx/workers/validate.js +18 -10
  41. package/tx/workers/worker.js +134 -47
  42. package/tx/xversion/xv-bundle.js +1 -2
  43. package/tx/xversion/xv-codesystem.js +5 -2
  44. package/tx/xversion/xv-parameters.js +4 -4
  45. package/tx/xversion/xv-resource.js +2 -2
  46. package/tx/xversion/xv-terminologyCapabilities.js +11 -6
  47. package/tx/xversion/xv-valueset.js +7 -7
  48. package/publisher/task-draft.js +0 -463
  49. package/tx/data/OperationDefinition-ValueSet-related.json +0 -133
package/tx/library.js CHANGED
@@ -1,7 +1,9 @@
1
1
  const fs = require('fs').promises;
2
2
  const path = require('path');
3
+ const crypto = require('crypto');
3
4
  const yaml = require('yaml'); // npm install yaml
4
5
  const { PackageManager, PackageContentLoader } = require('../library/package-manager');
6
+ const { FolderContentLoader } = require('../library/folder-content-loader');
5
7
  const { CodeSystem } = require("./library/codesystem");
6
8
  const {CountryCodeFactoryProvider} = require("./cs/cs-country");
7
9
  const {Iso4217FactoryProvider} = require("./cs/cs-currency");
@@ -295,6 +297,10 @@ class Library {
295
297
  await this.loadOcl(details, isDefault, mode);
296
298
  break;
297
299
 
300
+ case 'folder':
301
+ await this.loadFolder(details, isDefault, mode);
302
+ break;
303
+
298
304
  default:
299
305
  throw new Error(`Unknown source type: ${type}`);
300
306
  }
@@ -598,6 +604,23 @@ class Library {
598
604
  version = parts[1];
599
605
  }
600
606
  const packagePath = await packageManager.fetch(packageId, version);
607
+ await this.#loadPackageFromPath(packagePath, mode, csOnly);
608
+ }
609
+
610
+ async loadUrl(packageManager, url, isDefault, mode, csOnly) {
611
+ const packagePath = await packageManager.fetchUrl(url);
612
+ await this.#loadPackageFromPath(packagePath, mode, csOnly);
613
+ }
614
+
615
+ /**
616
+ * Shared loader for npm- and url-sourced packages. Given a fetched package
617
+ * path, loads its CodeSystems (and, unless csOnly, its ValueSets and
618
+ * ConceptMaps) into this library. Factored out of loadNpm/loadUrl so the two
619
+ * cannot drift apart (a past divergence here used Map-style .set() on the
620
+ * array-backed codeSystems list, breaking url sources).
621
+ * @private
622
+ */
623
+ async #loadPackageFromPath(packagePath, mode, csOnly) {
601
624
  if (mode === "fetch" || mode === "cs") {
602
625
  return;
603
626
  }
@@ -634,43 +657,64 @@ class Library {
634
657
  this.#logPackage(contentLoader.id(), contentLoader.version(), csc, vs ? vs.valueSetMap.size : 0);
635
658
  }
636
659
 
637
- async loadUrl(packageManager, url, isDefault, mode, csOnly) {
638
- const packagePath = await packageManager.fetchUrl(url);
660
+ /**
661
+ * Loads CodeSystem / ValueSet / ConceptMap resources from any *.json file in a folder.
662
+ * The folder is scanned (top level only); each JSON file is read and routed to the
663
+ * appropriate provider based on its resourceType. Files that fail to parse or whose
664
+ * resourceType isn't one of the three terminology types are silently skipped.
665
+ *
666
+ * Relative paths are resolved against the project root (same convention as loadUcum etc.).
667
+ *
668
+ * @param {string} details - The folder to scan
669
+ * @param {boolean} isDefault - Unused; folder sources don't register factories
670
+ * @param {string} mode - One of "fetch", "cs", "npm"
671
+ */
672
+ // eslint-disable-next-line no-unused-vars
673
+ async loadFolder(details, isDefault, mode) {
639
674
  if (mode === "fetch" || mode === "cs") {
640
675
  return;
641
676
  }
642
- const fullPackagePath = path.join(this.cacheFolder, packagePath);
643
- const contentLoader = new PackageContentLoader(fullPackagePath);
677
+
678
+ const folderPath = path.isAbsolute(details)
679
+ ? details
680
+ : path.resolve(path.join(__dirname, '..', details));
681
+
682
+ // Park the Package*Provider SQLite caches under the terminology cache rather
683
+ // than polluting the user's source folder, and wipe between runs so edits to
684
+ // the source folder are reliably picked up.
685
+ const hash = crypto.createHash('sha1').update(folderPath).digest('hex').substring(0, 16);
686
+ const cacheSubdir = path.join(this.cacheFolder, 'folder-source-' + hash);
687
+ await fs.rm(cacheSubdir, { recursive: true, force: true });
688
+ await fs.mkdir(cacheSubdir, { recursive: true });
689
+
690
+ const contentLoader = new FolderContentLoader(folderPath, cacheSubdir);
644
691
  await contentLoader.initialize();
645
692
 
646
- this.packageSources.push(contentLoader.id()+"#"+contentLoader.version());
693
+ this.packageSources.push(contentLoader.id() + "#" + contentLoader.version());
647
694
 
648
- let cp = new ListCodeSystemProvider();
649
- const resources = await contentLoader.getResourcesByType("CodeSystem");
695
+ const cp = new ListCodeSystemProvider();
696
+ const csEntries = await contentLoader.getResourcesByType("CodeSystem");
650
697
  let csc = 0;
651
- for (const resource of resources) {
652
- const cs = new CodeSystem(await contentLoader.loadFile(resource, contentLoader.fhirVersion()));
698
+ for (const entry of csEntries) {
699
+ const cs = new CodeSystem(await contentLoader.loadFile(entry, contentLoader.fhirVersion()));
653
700
  if (this.#isIgnored(cs.url, cs.version)) {
654
701
  this.log.info(`Ignoring CodeSystem ${cs.url}${cs.version ? '#' + cs.version : ''} (excluded by config)`);
655
702
  continue;
656
703
  }
657
704
  cs.sourcePackage = contentLoader.pid();
658
- cp.codeSystems.set(cs.url, cs);
659
- cp.codeSystems.set(cs.vurl, cs);
705
+ cp.codeSystems.push(cs);
660
706
  csc++;
661
707
  }
662
708
  this.codeSystemProviders.push(cp);
663
- let vs = null;
664
- if (!csOnly) {
665
- vs = new PackageValueSetProvider(contentLoader);
666
- await vs.initialize();
667
- this.valueSetProviders.push(vs);
668
- const cm = new PackageConceptMapProvider(contentLoader);
669
- await cm.initialize();
670
- this.conceptMapProviders.push(cm);
671
- }
672
709
 
673
- this.#logPackage(contentLoader.id(), contentLoader.version(), csc, vs ? vs.valueSetMap.size : 0);
710
+ const vs = new PackageValueSetProvider(contentLoader);
711
+ await vs.initialize();
712
+ this.valueSetProviders.push(vs);
713
+ const cm = new PackageConceptMapProvider(contentLoader);
714
+ await cm.initialize();
715
+ this.conceptMapProviders.push(cm);
716
+
717
+ this.#logPackage(contentLoader.id(), contentLoader.version(), csc, vs.valueSetMap.size);
674
718
  }
675
719
 
676
720
  /**
@@ -3,6 +3,7 @@ const inspector = require("inspector");
3
3
  const crypto = require("crypto");
4
4
  const {Languages} = require("../library/languages");
5
5
  const {Issue} = require("./library/operation-outcome");
6
+ const Logger = require("../library/logger");
6
7
 
7
8
  /**
8
9
  * Check if running under a debugger
@@ -59,6 +60,37 @@ class ResourceCache {
59
60
  this.stats = stats;
60
61
  this.cache = new Map();
61
62
  this.locks = new Map(); // For thread-safety with async operations
63
+ this.log = Logger.getInstance().child({module: 'tx-cache'});
64
+ // Running total of concepts across every resource in every entry, maintained
65
+ // incrementally on each mutation so cache sizing/limits are O(1) to consult.
66
+ // Each entry also carries its own `concepts` subtotal so removal/replacement
67
+ // can adjust the total without rescanning.
68
+ this.totalConcepts = 0;
69
+ // High-water marks (never decremented) - the most caches and the most concepts
70
+ // this cache has held at once, for capacity reporting.
71
+ this.maxConcepts = 0;
72
+ this.maxSizeValue = 0;
73
+ }
74
+
75
+ /**
76
+ * Update the high-water marks after a mutation that may have grown the cache.
77
+ */
78
+ _trackMax() {
79
+ if (this.totalConcepts > this.maxConcepts) {
80
+ this.maxConcepts = this.totalConcepts;
81
+ }
82
+ if (this.cache.size > this.maxSizeValue) {
83
+ this.maxSizeValue = this.cache.size;
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Concept count of a single cached resource, or 0 if it doesn't expose one.
89
+ * @param {Object} resource
90
+ * @returns {number}
91
+ */
92
+ _conceptsOf(resource) {
93
+ return resource && typeof resource.conceptCount === 'function' ? resource.conceptCount() : 0;
62
94
  }
63
95
 
64
96
  /**
@@ -70,8 +102,10 @@ class ResourceCache {
70
102
  const entry = this.cache.get(cacheId);
71
103
  if (entry) {
72
104
  entry.lastUsed = Date.now();
105
+ this.log.info(`cache-id '${cacheId}': hit, returning ${entry.resources.length} resource(s): ${entry.resources.map(r => this._resourceKey(r)).join(', ')}`);
73
106
  return [...entry.resources]; // Return a copy
74
107
  }
108
+ this.log.info(`cache-id '${cacheId}': miss (no entry)`);
75
109
  return [];
76
110
  }
77
111
 
@@ -92,22 +126,32 @@ class ResourceCache {
92
126
  add(cacheId, resources) {
93
127
  if (!resources || resources.length === 0) return;
94
128
 
95
- const entry = this.cache.get(cacheId) || { resources: [], lastUsed: Date.now() };
129
+ const entry = this.cache.get(cacheId) || { resources: [], lastUsed: Date.now(), concepts: 0 };
96
130
 
97
- // Merge resources, avoiding duplicates by url+version
131
+ // Merge resources, avoiding duplicates by url+version. Keep the entry's concept
132
+ // subtotal and the cache-wide total in step with each insertion/replacement.
98
133
  for (const resource of resources) {
99
134
  const key = this._resourceKey(resource);
135
+ const newConcepts = this._conceptsOf(resource);
100
136
  const existingIndex = entry.resources.findIndex(r => this._resourceKey(r) === key);
101
137
  if (existingIndex >= 0) {
102
- // Replace existing
138
+ // Replace existing: adjust by the difference
139
+ const delta = newConcepts - this._conceptsOf(entry.resources[existingIndex]);
103
140
  entry.resources[existingIndex] = resource;
141
+ entry.concepts += delta;
142
+ this.totalConcepts += delta;
143
+ this.log.info(`cache-id '${cacheId}': replaced ${key}`);
104
144
  } else {
105
145
  entry.resources.push(resource);
146
+ entry.concepts += newConcepts;
147
+ this.totalConcepts += newConcepts;
148
+ this.log.info(`cache-id '${cacheId}': added ${key}`);
106
149
  }
107
150
  }
108
151
 
109
152
  entry.lastUsed = Date.now();
110
153
  this.cache.set(cacheId, entry);
154
+ this._trackMax();
111
155
  }
112
156
 
113
157
  /**
@@ -116,10 +160,23 @@ class ResourceCache {
116
160
  * @param {Array} resources - Resources to set
117
161
  */
118
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(', ')}`);
164
+ // Drop the old entry's contribution, then count the replacement.
165
+ const existing = this.cache.get(cacheId);
166
+ if (existing) {
167
+ this.totalConcepts -= existing.concepts || 0;
168
+ }
169
+ let concepts = 0;
170
+ for (const resource of resources) {
171
+ concepts += this._conceptsOf(resource);
172
+ }
173
+ this.totalConcepts += concepts;
119
174
  this.cache.set(cacheId, {
120
175
  resources: [...resources],
121
- lastUsed: Date.now()
176
+ lastUsed: Date.now(),
177
+ concepts
122
178
  });
179
+ this._trackMax();
123
180
  }
124
181
 
125
182
  /**
@@ -127,6 +184,10 @@ class ResourceCache {
127
184
  * @param {string} cacheId - The cache identifier
128
185
  */
129
186
  clear(cacheId) {
187
+ const entry = this.cache.get(cacheId);
188
+ if (entry) {
189
+ this.totalConcepts -= entry.concepts || 0;
190
+ }
130
191
  this.cache.delete(cacheId);
131
192
  }
132
193
 
@@ -135,6 +196,7 @@ class ResourceCache {
135
196
  */
136
197
  clearAll() {
137
198
  this.cache.clear();
199
+ this.totalConcepts = 0;
138
200
  }
139
201
 
140
202
  /**
@@ -150,6 +212,7 @@ class ResourceCache {
150
212
  for (const [cacheId, entry] of this.cache.entries()) {
151
213
  if (now - entry.lastUsed > maxAge) {
152
214
  i++;
215
+ this.totalConcepts -= entry.concepts || 0;
153
216
  this.cache.delete(cacheId);
154
217
  }
155
218
  }
@@ -166,6 +229,41 @@ class ResourceCache {
166
229
  return this.cache.size;
167
230
  }
168
231
 
232
+ /**
233
+ * Total number of concepts held across every entry in the cache. Maintained
234
+ * incrementally, so this is O(1).
235
+ * @returns {number}
236
+ */
237
+ conceptCount() {
238
+ return this.totalConcepts;
239
+ }
240
+
241
+ /**
242
+ * Highest number of concepts this cache has held at once (high-water mark).
243
+ * @returns {number}
244
+ */
245
+ maxConceptCount() {
246
+ return this.maxConcepts;
247
+ }
248
+
249
+ /**
250
+ * Highest number of caches (cache-ids) this cache has held at once.
251
+ * @returns {number}
252
+ */
253
+ maxSize() {
254
+ return this.maxSizeValue;
255
+ }
256
+
257
+ /**
258
+ * Number of concepts held under a single cache-id (0 if unknown).
259
+ * @param {string} cacheId
260
+ * @returns {number}
261
+ */
262
+ conceptCountFor(cacheId) {
263
+ const entry = this.cache.get(cacheId);
264
+ return entry ? (entry.concepts || 0) : 0;
265
+ }
266
+
169
267
  /**
170
268
  * Generate a key for a resource based on url and version
171
269
  * @param {Object} resource - The resource
@@ -204,6 +302,10 @@ class ExpansionCache {
204
302
  this.cache = new Map();
205
303
  this.maxSize = maxSize;
206
304
  this.memoryThresholdBytes = memoryThresholdMB * 1024 * 1024;
305
+ // When true, every expansion is cached regardless of how long it took
306
+ // (bypasses MIN_CACHE_TIME_MS). Used by the test runner to force the cache
307
+ // path so cache correctness (e.g. language in the key) is exercised.
308
+ this.forceCaching = false;
207
309
  }
208
310
 
209
311
  /**
@@ -284,8 +386,9 @@ class ExpansionCache {
284
386
  * @returns {boolean} True if cached, false if duration too short
285
387
  */
286
388
  set(key, expansion, durationMs) {
287
- // Only cache if expansion took significant time
288
- if (durationMs < ExpansionCache.MIN_CACHE_TIME_MS) {
389
+ // Only cache if expansion took significant time, unless forceCaching is on
390
+ // (in which case everything is cached regardless of duration).
391
+ if (!this.forceCaching && durationMs < ExpansionCache.MIN_CACHE_TIME_MS) {
289
392
  return false;
290
393
  }
291
394
 
@@ -357,21 +460,6 @@ class ExpansionCache {
357
460
  return false;
358
461
  }
359
462
 
360
- /**
361
- * Force-store an expansion regardless of duration (for testing)
362
- * @param {string} key - Hash key
363
- * @param {Object} expansion - The expanded ValueSet
364
- */
365
- forceSet(key, expansion) {
366
- this.cache.set(key, {
367
- expansion: expansion,
368
- createdAt: Date.now(),
369
- lastUsed: Date.now(),
370
- durationMs: 0,
371
- hitCount: 0
372
- });
373
- }
374
-
375
463
  /**
376
464
  * Clear a specific entry
377
465
  * @param {string} key - Hash key
@@ -388,22 +476,22 @@ class ExpansionCache {
388
476
  }
389
477
 
390
478
  /**
391
- * Get cache statistics
479
+ * Get cache statistics.
480
+ * NB: named getStats(), not stats() — the `stats` field (the ServerStats
481
+ * passed to the constructor) would shadow a method called `stats`, making it
482
+ * unreachable.
392
483
  * @returns {Object} Stats object
393
484
  */
394
- stats() {
485
+ getStats() {
395
486
  let totalHits = 0;
396
- let totalDuration = 0;
397
487
  for (const entry of this.cache.values()) {
398
488
  totalHits += entry.hitCount;
399
- totalDuration += entry.durationMs;
400
489
  }
401
490
  return {
402
491
  size: this.cache.size,
403
492
  maxSize: this.maxSize,
404
493
  memoryThresholdMB: this.memoryThresholdBytes > 0 ? this.memoryThresholdBytes / (1024 * 1024) : 0,
405
- totalHits,
406
- totalDurationSaved: totalHits > 0 ? totalDuration * totalHits : 0
494
+ totalHits
407
495
  };
408
496
  }
409
497
 
@@ -447,6 +535,12 @@ class OperationContext {
447
535
  this.logEntries = [];
448
536
  this.resourceCache = resourceCache;
449
537
  this.expansionCache = expansionCache;
538
+ // Server-issued cache-id carried on the request (X-Cache-Id header). Set once
539
+ // per request from the header so every worker can consult it uniformly via
540
+ // setupAdditionalResources, regardless of how that worker assembles its
541
+ // Parameters (buildParameters, raw req.body, query/form). An explicit
542
+ // cache-id *parameter* still takes precedence over this.
543
+ this.cacheId = null;
450
544
  this.debugging = isDebugging();
451
545
  // Providers opened during this operation that need their underlying
452
546
  // resources (sqlite connections, etc.) released when the operation ends.
@@ -480,6 +574,7 @@ class OperationContext {
480
574
  newContext.timeTracker = this.timeTracker.link();
481
575
  newContext.logEntries = [...this.logEntries];
482
576
  newContext.debugging = this.debugging;
577
+ newContext.cacheId = this.cacheId;
483
578
  newContext.usageTracker = this.usageTracker;
484
579
  // Share the same provider-cleanup list so providers opened by the copy
485
580
  // are released when the parent operation ends.
package/tx/params.js CHANGED
@@ -53,6 +53,11 @@ class TxParameters {
53
53
 
54
54
  this.FHTTPLanguages = null;
55
55
  this.FDisplayLanguages = null;
56
+ // Whether languages were explicitly supplied by the request (vs a
57
+ // synthesised default). Consumed by hasHTTPLanguages/hasDisplayLanguages so
58
+ // the requested language folds into the expansion cache key.
59
+ this.FHasHTTPLanguages = false;
60
+ this.FHasDisplayLanguages = false;
56
61
  this.FValueSetVersionRules = null;
57
62
  this.FUid = '';
58
63
 
@@ -87,11 +92,15 @@ class TxParameters {
87
92
  if (!params.parameter) {
88
93
  return;
89
94
  }
90
- if (!this.hasHTTPLanguages && this.hasParam(params, "__Content-Language")) {
91
- this.HTTPLanguages = Languages.fromAcceptLanguage(this.paramstr(params, "__Content-Language"), this.languageDefinitions, !this.validating);
95
+ if (this.hasParam(params, "__Content-Language")) {
96
+ const lang = this.paramstr(params, "__Content-Language");
97
+ this.HTTPLanguages = Languages.fromAcceptLanguage(lang, this.languageDefinitions, !this.validating);
98
+ if (lang) this.FHasHTTPLanguages = true;
92
99
  }
93
- if (!this.hasHTTPLanguages && this.hasParam(params, "__Accept-Language")) {
94
- this.HTTPLanguages = Languages.fromAcceptLanguage(this.paramstr(params, "__Accept-Language"), this.languageDefinitions, !this.validating);
100
+ if (this.hasParam(params, "__Accept-Language")) {
101
+ const lang = this.paramstr(params, "__Accept-Language");
102
+ this.HTTPLanguages = Languages.fromAcceptLanguage(lang, this.languageDefinitions, !this.validating);
103
+ if (lang) this.FHasHTTPLanguages = true;
95
104
  }
96
105
 
97
106
  for (let p of params.parameter) {
@@ -124,7 +133,9 @@ class TxParameters {
124
133
 
125
134
  case 'displayLanguage': {
126
135
  try {
127
- this.DisplayLanguages = Languages.fromAcceptLanguage(getValuePrimitive(p), this.languageDefinitions, !this.validating);
136
+ const lang = getValuePrimitive(p);
137
+ this.DisplayLanguages = Languages.fromAcceptLanguage(lang, this.languageDefinitions, !this.validating);
138
+ if (lang) this.FHasDisplayLanguages = true;
128
139
  } catch (error) {
129
140
  throw new Issue("error", "processing", null, 'INVALID_DISPLAY_NAME', this.i18n.translate('INVALID_DISPLAY_NAME', this.HTTPLanguages, [getValuePrimitive(p)]), "invalid-display").handleAsOO(400);
130
141
  }
@@ -139,7 +150,10 @@ class TxParameters {
139
150
  break;
140
151
  }
141
152
  case 'no-cache': {
142
- if (getValuePrimitive(p) === 'true') this.uid = crypto.randomUUID();
153
+ // Write FUid (the field the cache key reads via hashSource); writing
154
+ // `this.uid` was a no-op so no-cache=true never busted the cache.
155
+ // Accept both the string ('true') and boolean (valueBoolean) forms.
156
+ if (strToBool(getValuePrimitive(p), false)) this.FUid = crypto.randomUUID();
143
157
  break;
144
158
  }
145
159
  case '_incomplete':
@@ -292,11 +306,11 @@ class TxParameters {
292
306
  }
293
307
 
294
308
  get hasHTTPLanguages() {
295
- return this.FHTTPLanguages && this.FHTTPLanguages.source;
309
+ return this.FHasHTTPLanguages;
296
310
  }
297
311
 
298
312
  get hasDisplayLanguages() {
299
- return this.FDisplayLanguages && this.FDisplayLanguages.source;
313
+ return this.FHasDisplayLanguages;
300
314
  }
301
315
 
302
316
  get hasDesignations() {
@@ -422,6 +436,7 @@ e
422
436
  if (value) {
423
437
  if (name === 'displayLanguage' && (!this.FDisplayLanguages || overwrite)) {
424
438
  this.DisplayLanguages = Languages.fromAcceptLanguage(getValuePrimitive(value), this.languageDefinitions, !this.validating)
439
+ if (getValuePrimitive(value)) this.FHasDisplayLanguages = true;
425
440
  }
426
441
 
427
442
  if (name === 'designation') {
@@ -561,6 +576,17 @@ e
561
576
  if (this.hasDesignations) {
562
577
  s = s + this.FDesignations.join(',') + '|';
563
578
  }
579
+ if (this.supplements && this.supplements.size > 0) {
580
+ // useSupplement changes the expansion result (and a bad supplement must
581
+ // error), so it must be part of the cache key. Sort for determinism.
582
+ s = s + '$' + [...this.supplements].sort().join(',') + '|';
583
+ }
584
+ // Further result-affecting parameters that were previously omitted from the
585
+ // key: the text filter (changes which codes expand), limited/incomplete
586
+ // expansion handling, whether abstract codes are included, and diagnostics.
587
+ // filter is free text, so JSON.stringify it to avoid delimiter collisions.
588
+ s = s + 'f:' + JSON.stringify(this.filter || '') + '|' +
589
+ b(this.limitedExpansion) + b(this.incompleteOK) + b(this.abstractOk) + b(this.diagnostics);
564
590
  for (let t of this.FVersionRules) {
565
591
  s = s + t.asString() + '|';
566
592
  }
@@ -623,9 +649,11 @@ e
623
649
 
624
650
  if (other.FHTTPLanguages) {
625
651
  this.FHTTPLanguages = other.FHTTPLanguages;
652
+ this.FHasHTTPLanguages = this.FHasHTTPLanguages || other.FHasHTTPLanguages;
626
653
  }
627
654
  if (other.FDisplayLanguages) {
628
655
  this.FDisplayLanguages = other.FDisplayLanguages;
656
+ this.FHasDisplayLanguages = this.FHasDisplayLanguages || other.FHasDisplayLanguages;
629
657
  }
630
658
  }
631
659
 
package/tx/provider.js CHANGED
@@ -1,7 +1,8 @@
1
1
  const { CodeSystem } = require("./library/codesystem");
2
2
  const {VersionUtilities} = require("../library/version-utilities");
3
3
  const { FhirCodeSystemProvider} = require("./cs/cs-cs");
4
- const {OperationContext, TerminologyError} = require("./operation-context");
4
+ const {OperationContext} = require("./operation-context");
5
+ const {TerminologyError} = require("./library/errors");
5
6
  const {validateParameter, validateOptionalParameter, validateArrayParameter} = require("../library/utilities");
6
7
  const path = require("path");
7
8
  const {PackageContentLoader} = require("../library/package-manager");
@@ -498,14 +499,16 @@ class Provider {
498
499
  deleteCodeSystem(cs) {
499
500
  this.codeSystems.delete(cs.vurl);
500
501
  this.codeSystems.delete(cs.url);
502
+ // If other versions of the SAME url survive, re-point the unversioned [url]
503
+ // entry at the most-recent surviving version. Otherwise leave it deleted.
501
504
  let existing = null;
502
505
  for (let t of this.codeSystems.values()) {
503
- if (!existing || t.isMoreRecent(existing)) {
506
+ if (t.url === cs.url && (!existing || t.isMoreRecent(existing))) {
504
507
  existing = t;
505
508
  }
506
509
  }
507
510
  if (existing) {
508
- this.codeSystems.set(cs.url, cs);
511
+ this.codeSystems.set(existing.url, existing);
509
512
  }
510
513
  }
511
514
 
package/tx/tx-html.js CHANGED
@@ -19,6 +19,7 @@ const {TerminologyCapabilitiesXML} = require("./xml/terminologycapabilities-xml"
19
19
  const {ParametersXML} = require("./xml/parameters-xml");
20
20
  const {OperationOutcomeXML} = require("./xml/operationoutcome-xml");
21
21
  const {debugLog} = require("./operation-context");
22
+ const {InvalidError} = require("./library/errors");
22
23
 
23
24
  const txHtmlLog = Logger.getInstance().child({ module: 'tx-html' });
24
25
 
@@ -311,6 +312,12 @@ class TxHtmlRenderer {
311
312
  return await this.buildHomePage(req);
312
313
  } else {
313
314
  try {
315
+ if (json === null || json === undefined || typeof json !== 'object' || Array.isArray(json)) {
316
+ throw new InvalidError(`Cannot render: expected a FHIR resource object but got ${json === null ? 'null' : (Array.isArray(json) ? 'an array' : typeof json)}`);
317
+ }
318
+ if (json.resourceType === undefined || json.resourceType === null || typeof json.resourceType !== 'string' || json.resourceType === '') {
319
+ throw new InvalidError(`Cannot render: resource has no resourceType (got ${json.resourceType === undefined ? 'undefined' : JSON.stringify(json.resourceType)})`);
320
+ }
314
321
  const _fmt = req?.query?._format || req?.query?.format || req?.body?._format;
315
322
  const op = req ? req.path.includes("$") : false;
316
323
  const resourceType = json.resourceType;
@@ -464,6 +471,33 @@ class TxHtmlRenderer {
464
471
  if (param.valueCode !== undefined) {
465
472
  return `<code>${escape(param.valueCode)}</code>`;
466
473
  }
474
+ if (param.valueId !== undefined) {
475
+ return escape(String(param.valueId));
476
+ }
477
+ if (param.valueOid !== undefined) {
478
+ return escape(String(param.valueOid));
479
+ }
480
+ if (param.valueUuid !== undefined) {
481
+ return escape(String(param.valueUuid));
482
+ }
483
+ if (param.valueMarkdown !== undefined) {
484
+ // Render markdown to HTML the same way the rest of the server does, using
485
+ // commonmark in safe mode (raw HTML in the markdown is escaped, so this is
486
+ // XSS-safe).
487
+ const commonmark = require('commonmark');
488
+ const reader = new commonmark.Parser();
489
+ const writer = new commonmark.HtmlRenderer({ safe: true });
490
+ return writer.render(reader.parse(String(param.valueMarkdown)));
491
+ }
492
+ if (param.valueInteger64 !== undefined) {
493
+ return escape(String(param.valueInteger64));
494
+ }
495
+ if (param.valuePositiveInt !== undefined) {
496
+ return escape(String(param.valuePositiveInt));
497
+ }
498
+ if (param.valueUnsignedInt !== undefined) {
499
+ return escape(String(param.valueUnsignedInt));
500
+ }
467
501
  if (param.valueDate !== undefined) {
468
502
  return escape(param.valueDate);
469
503
  }