fhirsmith 0.9.7 → 0.10.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.
@@ -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
@@ -437,6 +535,12 @@ class OperationContext {
437
535
  this.logEntries = [];
438
536
  this.resourceCache = resourceCache;
439
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;
440
544
  this.debugging = isDebugging();
441
545
  // Providers opened during this operation that need their underlying
442
546
  // resources (sqlite connections, etc.) released when the operation ends.
@@ -470,6 +574,7 @@ class OperationContext {
470
574
  newContext.timeTracker = this.timeTracker.link();
471
575
  newContext.logEntries = [...this.logEntries];
472
576
  newContext.debugging = this.debugging;
577
+ newContext.cacheId = this.cacheId;
473
578
  newContext.usageTracker = this.usageTracker;
474
579
  // Share the same provider-cleanup list so providers opened by the copy
475
580
  // are released when the parent operation ends.
package/tx/provider.js CHANGED
@@ -231,8 +231,8 @@ class Provider {
231
231
  return vs;
232
232
  }
233
233
  }
234
- let vs = await this.findKnownValueSet(url, version);
235
- return vs;
234
+ let vs1 = await this.findKnownValueSet(url, version);
235
+ return vs1;
236
236
  }
237
237
 
238
238
  async getConceptMapById(opContext, id) {
@@ -381,7 +381,7 @@ class Provider {
381
381
  if (vs) {
382
382
  return {
383
383
  link: this.path+"/ValueSet/"+vs.id,
384
- description: (vs.title ? vs.title : vs.name)+(version ? " v"+version : "")
384
+ description: (vs.title ? vs.title : vs.name)+(version ? " v"+version : vs.version ? " v"+vs.version : "")
385
385
  };
386
386
  }
387
387
  let cm = await this.findConceptMap(opContext, system, version);
package/tx/tx-html.js CHANGED
@@ -471,6 +471,33 @@ class TxHtmlRenderer {
471
471
  if (param.valueCode !== undefined) {
472
472
  return `<code>${escape(param.valueCode)}</code>`;
473
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
+ }
474
501
  if (param.valueDate !== undefined) {
475
502
  return escape(param.valueDate);
476
503
  }
package/tx/tx.js CHANGED
@@ -27,6 +27,7 @@ const LookupWorker = require('./workers/lookup');
27
27
  const SubsumesWorker = require('./workers/subsumes');
28
28
  const { MetadataHandler } = require('./workers/metadata');
29
29
  const { BatchValidateWorker } = require('./workers/batch-validate');
30
+ const { CacheControlWorker } = require('./workers/cache-control');
30
31
  const {CapabilityStatementXML} = require("./xml/capabilitystatement-xml");
31
32
  const {TerminologyCapabilitiesXML} = require("./xml/terminologycapabilities-xml");
32
33
  const {ParametersXML} = require("./xml/parameters-xml");
@@ -36,7 +37,7 @@ const {ConceptMapXML} = require("./xml/conceptmap-xml");
36
37
  const {TxHtmlRenderer} = require("./tx-html");
37
38
  const {Renderer} = require("./library/renderer");
38
39
  const {OperationsWorker} = require("./workers/operations");
39
- const {RelatedWorker} = require("./workers/related");
40
+ const {CompareWorker} = require("./workers/compare");
40
41
  const {codeSystemFromR5} = require("./xversion/xv-codesystem");
41
42
  const {operationOutcomeFromR5} = require("./xversion/xv-operationoutcome");
42
43
  const {parametersFromR5} = require("./xversion/xv-parameters");
@@ -296,6 +297,12 @@ class TXModule {
296
297
  endpointInfo.resourceCache, endpointInfo.expansionCache
297
298
  );
298
299
  opContext.usageTracker = this.usageTracker;
300
+ // Normalise the cache-id from the X-Cache-Id header onto the operation
301
+ // context once, here, so every worker honours a front-loaded cache no
302
+ // matter how it later assembles its Parameters (some use buildParameters,
303
+ // others read req.body / query directly). An explicit cache-id parameter
304
+ // on the request still wins (see setupAdditionalResources).
305
+ opContext.cacheId = req.get('X-Cache-Id') || null;
299
306
 
300
307
  // Attach everything to request
301
308
  req.txProvider = endpointInfo.provider;
@@ -595,23 +602,23 @@ class TXModule {
595
602
  }
596
603
  });
597
604
 
598
- // ValueSet/$related(GET and POST)
599
- router.get('/ValueSet/\\$related', async (req, res) => {
605
+ // ValueSet/$compare(GET and POST)
606
+ router.get('/ValueSet/\\$compare', async (req, res) => {
600
607
  const start = Date.now();
601
608
  try {
602
- let worker = new RelatedWorker(req.txOpContext, this.log, req.txProvider, this.languages, this.i18n);
609
+ let worker = new CompareWorker(req.txOpContext, this.log, req.txProvider, this.languages, this.i18n);
603
610
  await worker.handle(req, res);
604
611
  } finally {
605
- this.countRequest('$related', Date.now() - start);
612
+ this.countRequest('$compare', Date.now() - start);
606
613
  }
607
614
  });
608
- router.post('/ValueSet/\\$related', async (req, res) => {
615
+ router.post('/ValueSet/\\$compare', async (req, res) => {
609
616
  const start = Date.now();
610
617
  try {
611
- let worker = new RelatedWorker(req.txOpContext, this.log, req.txProvider, this.languages, this.i18n);
618
+ let worker = new CompareWorker(req.txOpContext, this.log, req.txProvider, this.languages, this.i18n);
612
619
  await worker.handle(req, res);
613
620
  } finally {
614
- this.countRequest('$related', Date.now() - start);
621
+ this.countRequest('$compare', Date.now() - start);
615
622
  }
616
623
  });
617
624
 
@@ -655,6 +662,26 @@ class TXModule {
655
662
  }
656
663
  });
657
664
 
665
+ // $cache-control (GET and POST) - base-level operation: mode=start|end
666
+ router.get('/\\$cache-control', async (req, res) => {
667
+ const start = Date.now();
668
+ try {
669
+ let worker = new CacheControlWorker(req.txOpContext, this.log, req.txProvider, this.languages, this.i18n);
670
+ await worker.handle(req, res, this.log);
671
+ } finally {
672
+ this.countRequest('$cache-control', Date.now() - start);
673
+ }
674
+ });
675
+ router.post('/\\$cache-control', async (req, res) => {
676
+ const start = Date.now();
677
+ try {
678
+ let worker = new CacheControlWorker(req.txOpContext, this.log, req.txProvider, this.languages, this.i18n);
679
+ await worker.handle(req, res, this.log);
680
+ } finally {
681
+ this.countRequest('$cache-control', Date.now() - start);
682
+ }
683
+ });
684
+
658
685
  // ConceptMap/$translate (GET and POST)
659
686
  router.get('/ConceptMap/\\$translate', async (req, res) => {
660
687
  const start = Date.now();
@@ -779,23 +806,23 @@ class TXModule {
779
806
  });
780
807
 
781
808
 
782
- // ValueSet/[id]/$related
783
- router.get('/ValueSet/:id/\\$related', async (req, res) => {
809
+ // ValueSet/[id]/$compare
810
+ router.get('/ValueSet/:id/\\$compare', async (req, res) => {
784
811
  const start = Date.now();
785
812
  try {
786
- let worker = new RelatedWorker(req.txOpContext, this.log, req.txProvider, this.languages, this.i18n);
813
+ let worker = new CompareWorker(req.txOpContext, this.log, req.txProvider, this.languages, this.i18n);
787
814
  await worker.handleInstance(req, res, this.log);
788
815
  } finally {
789
- this.countRequest('$related', Date.now() - start);
816
+ this.countRequest('$compare', Date.now() - start);
790
817
  }
791
818
  });
792
- router.post('/ValueSet/:id/\\$related', async (req, res) => {
819
+ router.post('/ValueSet/:id/\\$compare', async (req, res) => {
793
820
  const start = Date.now();
794
821
  try {
795
- let worker = new RelatedWorker(req.txOpContext, this.log, req.txProvider, this.languages, this.i18n);
822
+ let worker = new CompareWorker(req.txOpContext, this.log, req.txProvider, this.languages, this.i18n);
796
823
  await worker.handleInstance(req, res, this.log);
797
824
  } finally {
798
- this.countRequest('$related', Date.now() - start);
825
+ this.countRequest('$compare', Date.now() - start);
799
826
  }
800
827
  });
801
828
 
@@ -1177,10 +1204,46 @@ class TXModule {
1177
1204
  }
1178
1205
  }
1179
1206
 
1180
- cacheCount() {
1207
+ // Number of entries in the expansion cache, summed across endpoints.
1208
+ expansionItemCount() {
1209
+ let count = 0;
1210
+ for (let ep of this.endpoints) {
1211
+ count = count + ep.expansionCache.size();
1212
+ }
1213
+ return count;
1214
+ }
1215
+
1216
+ // Number of concepts held in the client (resource) cache, summed across endpoints.
1217
+ clientConceptCount() {
1218
+ let count = 0;
1219
+ for (let ep of this.endpoints) {
1220
+ count = count + ep.resourceCache.conceptCount();
1221
+ }
1222
+ return count;
1223
+ }
1224
+
1225
+ // Number of caches (cache-ids) currently held in the client cache, across endpoints.
1226
+ clientCacheCount() {
1227
+ let count = 0;
1228
+ for (let ep of this.endpoints) {
1229
+ count = count + ep.resourceCache.size();
1230
+ }
1231
+ return count;
1232
+ }
1233
+
1234
+ // High-water marks for the client cache, summed across endpoints.
1235
+ maxClientCacheCount() {
1236
+ let count = 0;
1237
+ for (let ep of this.endpoints) {
1238
+ count = count + ep.resourceCache.maxSize();
1239
+ }
1240
+ return count;
1241
+ }
1242
+
1243
+ maxClientConceptCount() {
1181
1244
  let count = 0;
1182
1245
  for (let ep of this.endpoints) {
1183
- count = count + ep.resourceCache.size() + ep.expansionCache.size();
1246
+ count = count + ep.resourceCache.maxConceptCount();
1184
1247
  }
1185
1248
  return count;
1186
1249
  }
@@ -7,6 +7,30 @@ const ValueSet = require("../library/valueset");
7
7
  // Columns that can be returned directly without parsing JSON
8
8
  const INDEXED_COLUMNS = ['id', 'url', 'version', 'date', 'description', 'name', 'publisher', 'status', 'title'];
9
9
 
10
+ /**
11
+ * Is `candidate` a newer version than `current`? Used to keep the version-less
12
+ * (bare-url) and url|major.minor map keys pointing at the latest version when several
13
+ * versions of the same value set are present. Handles both semver (IG/package value
14
+ * sets) and VSAC-style YYYYMMDD date versions (not semver, but fixed-width so they
15
+ * order correctly numerically).
16
+ * @param {string|null|undefined} candidate
17
+ * @param {string|null|undefined} current
18
+ * @returns {boolean}
19
+ */
20
+ function isNewerVersion(candidate, current) {
21
+ if (current == null) return true;
22
+ if (candidate == null) return false;
23
+ if (candidate === current) return false;
24
+ if (VersionUtilities.isSemVer(candidate) && VersionUtilities.isSemVer(current)) {
25
+ return VersionUtilities.isThisOrLater(current, candidate); // candidate >= current (semver-aware)
26
+ }
27
+ if (/^\d+$/.test(candidate) && /^\d+$/.test(current)) {
28
+ // e.g. VSAC YYYYMMDD - compare numerically (handles differing widths too)
29
+ return Number(candidate) > Number(current);
30
+ }
31
+ return candidate > current; // best-effort lexical fallback
32
+ }
33
+
10
34
  /**
11
35
  * Shared database layer for ValueSet providers
12
36
  * Handles SQLite operations for indexing and searching ValueSets
@@ -737,21 +761,22 @@ class ValueSetDatabase {
737
761
  }
738
762
 
739
763
  addToMap(valueSetMap, id, url, version, valueSet) {
740
- valueSetMap.set(url, valueSet);
741
- valueSetMap.set(id, valueSet);
764
+ // The bare-url key must resolve to the *latest* version, but rows arrive in no
765
+ // particular order (and several versions of the same url coexist - e.g. VSAC, where
766
+ // each date-version is its own row). So only overwrite it when this version is newer.
767
+ this._setIfNewer(valueSetMap, url, version, valueSet);
768
+ valueSetMap.set(id, valueSet); // id is unique per version
742
769
 
743
770
  if (version) {
744
- // Store by url|version
745
- const versionKey = `${url}|${version}`;
746
- valueSetMap.set(versionKey, valueSet);
771
+ // Store by url|version (exact - unique per version, always set)
772
+ valueSetMap.set(`${url}|${version}`, valueSet);
747
773
 
748
- // If version is semver, also store by url|major.minor
774
+ // If version is semver, also store by url|major.minor, keeping the latest patch
749
775
  try {
750
776
  if (VersionUtilities.isSemVer(version)) {
751
777
  const majorMinor = VersionUtilities.getMajMin(version);
752
778
  if (majorMinor) {
753
- const majorMinorKey = `${url}|${majorMinor}`;
754
- valueSetMap.set(majorMinorKey, valueSet);
779
+ this._setIfNewer(valueSetMap, `${url}|${majorMinor}`, version, valueSet);
755
780
  }
756
781
  }
757
782
  } catch (error) {
@@ -760,6 +785,14 @@ class ValueSetDatabase {
760
785
  }
761
786
  }
762
787
 
788
+ // Set map[key] = valueSet only if `version` is newer than the version already there.
789
+ _setIfNewer(valueSetMap, key, version, valueSet) {
790
+ const existing = valueSetMap.get(key);
791
+ if (!existing || isNewerVersion(version, existing.version)) {
792
+ valueSetMap.set(key, valueSet);
793
+ }
794
+ }
795
+
763
796
  /**
764
797
  * Search for ValueSets based on criteria
765
798
  * @param {Array<{name: string, value: string}>} searchParams - Search criteria
@@ -26,6 +26,9 @@ class PackageValueSetProvider extends AbstractValueSetProvider {
26
26
  this.sourcePackageCode = packageLoader.id();
27
27
  }
28
28
 
29
+ code() {
30
+ return this.sourcePackageCode;
31
+ }
29
32
  sourcePackage() {
30
33
  return this.sourcePackageCode;
31
34
  }
package/tx/vs/vs-vsac.js CHANGED
@@ -66,6 +66,10 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
66
66
  });
67
67
  }
68
68
 
69
+ code() {
70
+ return "vsac";
71
+ }
72
+
69
73
  sourcePackage() {
70
74
  return "vsac";
71
75
  }