fhirsmith 0.9.7 → 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.
@@ -2,15 +2,34 @@
2
2
  // Crawler for gathering server information from terminology servers
3
3
 
4
4
  const axios = require('axios');
5
- const {
6
- ServerRegistries,
7
- ServerRegistry,
8
- ServerInformation,
5
+ const dns = require('dns');
6
+ const http = require('http');
7
+ const https = require('https');
8
+ const ipaddr = require('ipaddr.js');
9
+ const {
10
+ ServerRegistries,
11
+ ServerRegistry,
12
+ ServerInformation,
9
13
  ServerVersionInformation,
10
14
  } = require('./model');
11
15
  const {Extensions} = require("../tx/library/extensions");
12
16
  const {debugLog} = require("../tx/operation-context");
13
17
 
18
+ // True if an IP literal is anything other than a normal public (unicast) address:
19
+ // loopback, private, link-local (incl. 169.254.169.254 cloud metadata), unique-local,
20
+ // CGNAT, multicast, reserved, etc. IPv4-mapped IPv6 addresses are unwrapped first.
21
+ function isNonPublicAddress(ip) {
22
+ try {
23
+ let addr = ipaddr.parse(ip);
24
+ if (addr.kind() === 'ipv6' && addr.isIPv4MappedAddress()) {
25
+ addr = addr.toIPv4Address();
26
+ }
27
+ return addr.range() !== 'unicast';
28
+ } catch (e) {
29
+ return true; // unparseable - treat as unsafe
30
+ }
31
+ }
32
+
14
33
  const MASTER_URL = 'https://fhir.github.io/ig-registry/tx-servers.json';
15
34
 
16
35
  class RegistryCrawler {
@@ -22,7 +41,8 @@ class RegistryCrawler {
22
41
  masterUrl: config.masterUrl || MASTER_URL,
23
42
  userAgent: config.userAgent || 'HealthIntersections/FhirServer',
24
43
  crawlInterval: config.crawlInterval || 5 * 60 * 1000, // 5 minutes default
25
- apiKeys: config.apiKeys || {} // Map of server URL or code to API key
44
+ apiKeys: config.apiKeys || {}, // Map of server URL or code to API key
45
+ allowPrivateAddresses: config.allowPrivateAddresses || false // SSRF opt-out for local test servers
26
46
  };
27
47
  this.stats = stats;
28
48
 
@@ -39,6 +59,63 @@ class RegistryCrawler {
39
59
  this.log = logv;
40
60
  }
41
61
 
62
+ // A DNS lookup wrapper that rejects any host resolving to a non-public address.
63
+ // Enforced at connection time (so it also covers redirect targets and defeats
64
+ // DNS-rebinding), this is the SSRF guard for outbound fetches. The registry is
65
+ // crawled from server-supplied "next" links, so the target host is not trusted.
66
+ // Set config.allowPrivateAddresses = true to disable (e.g. for local test servers).
67
+ ssrfLookup() {
68
+ const allowPrivate = !!(this.config && this.config.allowPrivateAddresses);
69
+ return (hostname, options, callback) => {
70
+ if (typeof options === 'function') {
71
+ callback = options;
72
+ options = {};
73
+ }
74
+ const wantAll = !!(options && options.all);
75
+ dns.lookup(hostname, Object.assign({}, options, { all: true }), (err, addresses) => {
76
+ if (err) {
77
+ callback(err);
78
+ return;
79
+ }
80
+ if (!allowPrivate) {
81
+ for (const a of addresses) {
82
+ if (isNonPublicAddress(a.address)) {
83
+ callback(new Error('Blocked request to non-public address ' + a.address + ' (host ' + hostname + ')'));
84
+ return;
85
+ }
86
+ }
87
+ }
88
+ if (wantAll) {
89
+ callback(null, addresses);
90
+ } else {
91
+ callback(null, addresses[0].address, addresses[0].family);
92
+ }
93
+ });
94
+ };
95
+ }
96
+
97
+ // http/https agents that route every connection through the SSRF lookup.
98
+ guardedAgents() {
99
+ if (!this._guardedAgents) {
100
+ const lookup = this.ssrfLookup();
101
+ class GuardedHttpAgent extends http.Agent {
102
+ createConnection(options, cb) {
103
+ return super.createConnection(Object.assign({}, options, { lookup }), cb);
104
+ }
105
+ }
106
+ class GuardedHttpsAgent extends https.Agent {
107
+ createConnection(options, cb) {
108
+ return super.createConnection(Object.assign({}, options, { lookup }), cb);
109
+ }
110
+ }
111
+ this._guardedAgents = {
112
+ httpAgent: new GuardedHttpAgent({ keepAlive: true }),
113
+ httpsAgent: new GuardedHttpsAgent({ keepAlive: true })
114
+ };
115
+ }
116
+ return this._guardedAgents;
117
+ }
118
+
42
119
 
43
120
  /**
44
121
  * Main entry point - crawl the registry starting from the master URL
@@ -455,7 +532,9 @@ class RegistryCrawler {
455
532
  timeout: this.config.timeout,
456
533
  headers: headers,
457
534
  signal: this.abortController?.signal,
458
- validateStatus: (status) => status < 500 // Don't throw on 4xx
535
+ validateStatus: (status) => status < 500, // Don't throw on 4xx
536
+ httpAgent: this.guardedAgents().httpAgent,
537
+ httpsAgent: this.guardedAgents().httpsAgent
459
538
  });
460
539
 
461
540
  if (response.status >= 400) {
@@ -976,7 +976,15 @@ class RegistryModule {
976
976
 
977
977
  try {
978
978
  const params = this._normalizeQueryParams(req.query);
979
- const {fhirVersion, url, valueSet, usage} = params;
979
+ const {fhirVersion, valueSet, usage, version} = params;
980
+ let {url} = params;
981
+
982
+ // If a version was supplied separately, fold it into the code system
983
+ // URL using the canonical url|version syntax that the resolver expects.
984
+ // Don't double up if the caller already encoded a version in the url.
985
+ if (version && url && !url.includes('|')) {
986
+ url = `${url}|${version}`;
987
+ }
980
988
 
981
989
  // Convert authoritativeOnly to boolean
982
990
  const authoritativeOnly = params.authoritativeOnly === 'true';
@@ -1219,6 +1227,7 @@ class RegistryModule {
1219
1227
  buildResolveFormContent(queryParams = {}) {
1220
1228
  const fhirVersion = queryParams.fhirVersion || '';
1221
1229
  const url = queryParams.url || '';
1230
+ const version = queryParams.version || '';
1222
1231
  const valueSet = queryParams.valueSet || '';
1223
1232
  const authoritativeOnly = queryParams.authoritativeOnly === 'true';
1224
1233
 
@@ -1246,6 +1255,14 @@ class RegistryModule {
1246
1255
  html += '</p>';
1247
1256
  html += '<p class="text-muted small">Example: http://loinc.org</p>';
1248
1257
 
1258
+ // Code System Version field (optional)
1259
+ html += '<p>';
1260
+ html += '<label for="version" class="form-label fw-bold">Code System Version</label>';
1261
+ html += `<input type="text" class="form-control" id="version" name="version"
1262
+ value="${escape(version)}">`;
1263
+ html += '</p>';
1264
+ html += '<p class="text-muted small">Optional. Example: 2.74 (combined with the Code System URL as url|version)</p>';
1265
+
1249
1266
  // ValueSet URL field - now vertical
1250
1267
  html += '<p>';
1251
1268
  html += '<label for="valueSet" class="form-label fw-bold">Value Set URL</label>';
package/server.js CHANGED
@@ -430,6 +430,11 @@ async function buildRootPageContent() {
430
430
  content += `<td style="background-color:${pctColor(processPCT)}"><strong>Process Memory:</strong> ${rssMB} MB of ${memLimitMB} MB (${processPCT.toFixed(0)}%)</td>`;
431
431
  content += `<td style="background-color:${pctColor(sysMemPCT)}"><strong>System Memory:</strong> ${usedMemMB} MB of ${totalMemMB} MB (${sysMemPCT.toFixed(0)}%)</td>`;
432
432
  content += '</tr>';
433
+ content += '<tr>';
434
+ content += `<td><strong>Expansion Cache:</strong> ${stats.expansionItems()} items</td>`;
435
+ content += `<td><strong>Client Cache:</strong> ${stats.clientCaches()} caches / ${stats.clientConcepts()} concepts</td>`;
436
+ content += `<td><strong>Max Client Cache:</strong> ${stats.maxClientCaches()} caches / ${stats.maxClientConcepts()} concepts</td>`;
437
+ content += '</tr>';
433
438
  content += getLogStats();
434
439
  content += '</table>';
435
440
 
package/stats.js CHANGED
@@ -36,24 +36,26 @@ class ServerStats {
36
36
  : (now - this.startTime) / 60000;
37
37
  const requestsPerMin = minutesSinceStart > 0 ? requestsDelta / minutesSinceStart : 0;
38
38
 
39
- const currentCpu = this.readSystemCpu();
40
- const idleDelta = currentCpu.idle - this.lastUsage.idle;
41
- const totalDelta = currentCpu.total - this.lastUsage.total;
42
- const percent = totalDelta > 0 ? 100 * (1 - idleDelta / totalDelta) : 0;
43
-
44
39
  const loopDelay = this.eventLoopMonitor.mean / 1e6;
45
- let cacheCount = 0;
40
+ // Two distinct caches: the expansion cache (count entries) and the client
41
+ // (resource) cache (count concepts held - a sense of how much it's carrying).
42
+ let expansionItems = 0;
43
+ let clientConcepts = 0;
46
44
  for (let m of this.cachingModules) {
47
- cacheCount = cacheCount + m.cacheCount();
45
+ if (typeof m.expansionItemCount === 'function') {
46
+ expansionItems = expansionItems + m.expansionItemCount();
47
+ }
48
+ if (typeof m.clientConceptCount === 'function') {
49
+ clientConcepts = clientConcepts + m.clientConceptCount();
50
+ }
48
51
  }
49
52
 
50
- this.history.push({time: now, mem: currentMem - this.startMem, rpm: requestsPerMin, tat: requestsTat, cpu: percent, block: loopDelay, cache : cacheCount});
53
+ this.history.push({time: now, mem: currentMem - this.startMem, rpm: requestsPerMin, tat: requestsTat, block: loopDelay, expansion: expansionItems, clientConcepts: clientConcepts});
51
54
 
52
55
  this.eventLoopMonitor.reset();
53
56
  this.requestCountSnapshot = combinedCount;
54
57
  this.requestTime = 0;
55
58
  this.lastTime = now;
56
- this.lastUsage = currentCpu;
57
59
 
58
60
  // Prune old data (keep 24 hours)
59
61
  const cutoff = now - (24 * 60 * 60 * 1000); // 24 hours ago
@@ -65,7 +67,6 @@ class ServerStats {
65
67
  this.started = true;
66
68
  this.startMem = process.memoryUsage().heapUsed;
67
69
  this.startTime = Date.now();
68
- this.lastUsage = this.readSystemCpu();
69
70
  this.lastTime = this.startTime;
70
71
  this.eventLoopMonitor = monitorEventLoopDelay({ resolution: 20 });
71
72
  this.eventLoopMonitor.enable();
@@ -141,17 +142,24 @@ class ServerStats {
141
142
  clearInterval(this.timer);
142
143
  }
143
144
 
144
- readSystemCpu() {
145
- const os = require('os');
146
- const cpus = os.cpus();
147
- let idle = 0, total = 0;
148
- for (const cpu of cpus) {
149
- idle += cpu.times.idle;
150
- total += cpu.times.user + cpu.times.nice + cpu.times.sys + cpu.times.idle + cpu.times.irq;
145
+ // Live cache aggregates across all registered caching modules (defensive: a
146
+ // module that doesn't expose a given metric contributes 0).
147
+ _sumCachingModules(method) {
148
+ let total = 0;
149
+ for (let m of this.cachingModules) {
150
+ if (typeof m[method] === 'function') {
151
+ total = total + m[method]();
152
+ }
151
153
  }
152
- return { idle, total };
154
+ return total;
153
155
  }
154
156
 
157
+ expansionItems() { return this._sumCachingModules('expansionItemCount'); }
158
+ clientCaches() { return this._sumCachingModules('clientCacheCount'); }
159
+ clientConcepts() { return this._sumCachingModules('clientConceptCount'); }
160
+ maxClientCaches() { return this._sumCachingModules('maxClientCacheCount'); }
161
+ maxClientConcepts() { return this._sumCachingModules('maxClientConceptCount'); }
162
+
155
163
  getTaskColor(status) {
156
164
  switch (status) {
157
165
  case "started": return "LightGrey";
@@ -1100,6 +1100,7 @@ Unable_to_resolve_system__value_set_has_multiple_matches = The System URI could
1100
1100
  Unable_to_resolve_system__value_set_has_no_includes_or_expansion = The System URI could not be determined for the code ''{0}'' in the ValueSet ''{1}'': value set has no includes or expansion
1101
1101
  UNABLE_TO_RESOLVE_SYSTEM_SYSTEM_IS_INDETERMINATE = The code system {1} referred to from value set {0} has a grammar, and the code might be valid in it
1102
1102
  Unable_to_resolve_value_Set_ = A definition for the value Set ''{0}'' could not be found
1103
+ CACHE_ID_UNKNOWN = The cache ''{0}'' is not known to this server. Caches are created with $cache-control?mode=start; this one was never created, or has expired or been released
1103
1104
  Unable_to_validate_code_without_using_server = Unable to validate code without using server because: {0}
1104
1105
  Undefined_attribute__on__for_type__properties__ = Undefined attribute ''@{0}'' on {1} for type {2}
1105
1106
  Undefined_element_ = Undefined element ''{0}'' at {1}
@@ -4,7 +4,7 @@
4
4
  <canvas id="memoryChart" height="200"></canvas>
5
5
  </div>
6
6
  <div style="flex: 1;">
7
- <h6>Cache</h6>
7
+ <h6>Expansion Cache</h6>
8
8
  <canvas id="cacheChart" height="200"></canvas>
9
9
  </div>
10
10
  <div style="flex: 1;">
@@ -18,8 +18,8 @@
18
18
  <canvas id="requestChart" height="200"></canvas>
19
19
  </div>
20
20
  <div style="flex: 1;">
21
- <h6>CPU Usage (%)</h6>
22
- <canvas id="cpuChart" height="200"></canvas>
21
+ <h6>Client Cache</h6>
22
+ <canvas id="clientCacheChart" height="200"></canvas>
23
23
  </div>
24
24
  <div style="flex: 1;">
25
25
  <h6>Node Blocking</h6>
@@ -99,13 +99,13 @@
99
99
  }
100
100
  });
101
101
 
102
- // Cache chart
102
+ // Expansion cache chart
103
103
  new Chart(document.getElementById('cacheChart'), {
104
104
  type: 'line',
105
105
  data: {
106
106
  labels: historyData.map(d => formatTime(d.time)),
107
107
  datasets: [{
108
- data: historyData.map(d => d.cache),
108
+ data: historyData.map(d => d.expansion),
109
109
  borderColor: '#7b7b7b',
110
110
  backgroundColor: 'rgba(123, 123, 123, 0.1)',
111
111
  fill: true,
@@ -121,7 +121,7 @@
121
121
  ...chartOptions.scales.y,
122
122
  title: {
123
123
  display: true,
124
- text: '# cached items'
124
+ text: 'Expansion cache # items'
125
125
  }
126
126
  }
127
127
  }
@@ -158,13 +158,13 @@
158
158
  }
159
159
  });
160
160
 
161
- // CPU chart
162
- new Chart(document.getElementById('cpuChart'), {
161
+ // Client cache concepts chart
162
+ new Chart(document.getElementById('clientCacheChart'), {
163
163
  type: 'line',
164
164
  data: {
165
165
  labels: historyData.map(d => formatTime(d.time)),
166
166
  datasets: [{
167
- data: historyData.map(d => parseFloat(formatMB(d.cpu))),
167
+ data: historyData.map(d => d.clientConcepts),
168
168
  borderColor: '#ff7b00',
169
169
  backgroundColor: 'rgba(255, 123, 0, 0.1)',
170
170
  fill: true,
@@ -180,7 +180,7 @@
180
180
  ...chartOptions.scales.y,
181
181
  title: {
182
182
  display: true,
183
- text: '%'
183
+ text: 'Client Cache # concepts'
184
184
  }
185
185
  }
186
186
  }
@@ -42,6 +42,37 @@ class CodeSystem extends CanonicalResource {
42
42
  }
43
43
  this.buildMaps();
44
44
  }
45
+ // Precalculated at construction so callers (e.g. the resource cache) have a
46
+ // cheap O(1) sense of how large this resource is.
47
+ this._conceptCount = CodeSystem._countConcepts(this.jsonObj.concept);
48
+ }
49
+
50
+ /**
51
+ * Number of concepts defined in this CodeSystem, counted recursively (nested
52
+ * concepts included). Precalculated at construction time.
53
+ * @returns {number}
54
+ */
55
+ conceptCount() {
56
+ return this._conceptCount;
57
+ }
58
+
59
+ /**
60
+ * Recursively count concepts (including nested `concept` children).
61
+ * @param {Array} concepts
62
+ * @returns {number}
63
+ */
64
+ static _countConcepts(concepts) {
65
+ if (!Array.isArray(concepts)) {
66
+ return 0;
67
+ }
68
+ let n = 0;
69
+ for (const c of concepts) {
70
+ n++;
71
+ if (c && c.concept) {
72
+ n += CodeSystem._countConcepts(c.concept);
73
+ }
74
+ }
75
+ return n;
45
76
  }
46
77
 
47
78
  /**
@@ -20,6 +20,30 @@ class ConceptMap extends CanonicalResource {
20
20
  this.jsonObj = conceptMapToR5(jsonObj, fhirVersion);
21
21
  this.validate();
22
22
  this.id = this.jsonObj.id;
23
+ // Precalculated at construction so callers (e.g. the resource cache) have a
24
+ // cheap O(1) sense of how large this resource is.
25
+ this._conceptCount = ConceptMap._countConcepts(this.jsonObj);
26
+ }
27
+
28
+ /**
29
+ * Number of concept nodes this ConceptMap carries: every source `element` plus
30
+ * every `target` across all groups (each is a coded node, so this approximates
31
+ * the resource's size). Precalculated at construction time.
32
+ * @returns {number}
33
+ */
34
+ conceptCount() {
35
+ return this._conceptCount;
36
+ }
37
+
38
+ static _countConcepts(jsonObj) {
39
+ let n = 0;
40
+ for (const g of ((jsonObj && jsonObj.group) || [])) {
41
+ for (const e of (g.element || [])) {
42
+ n++;
43
+ n += Array.isArray(e.target) ? e.target.length : 0;
44
+ }
45
+ }
46
+ return n;
23
47
  }
24
48
 
25
49
  /**
@@ -25,6 +25,52 @@ class ValueSet extends CanonicalResource {
25
25
  this.jsonObj = valueSetToR5(jsonObj, fhirVersion);
26
26
  this.validate();
27
27
  this.buildMaps();
28
+ // Precalculated at construction so callers (e.g. the resource cache) have a
29
+ // cheap O(1) sense of how large this resource is.
30
+ this._conceptCount = ValueSet._countConcepts(this.jsonObj);
31
+ }
32
+
33
+ /**
34
+ * Number of concepts this ValueSet carries: the enumerated concepts in its
35
+ * compose (include + exclude `concept` lists) plus any concepts in an inline
36
+ * expansion (`expansion.contains`, counted recursively). Note this counts the
37
+ * concepts *present in the resource*, not the (possibly unbounded) size of the
38
+ * value set's full expansion. Precalculated at construction time.
39
+ * @returns {number}
40
+ */
41
+ conceptCount() {
42
+ return this._conceptCount;
43
+ }
44
+
45
+ static _countConcepts(jsonObj) {
46
+ let n = 0;
47
+ const compose = jsonObj && jsonObj.compose;
48
+ if (compose) {
49
+ for (const inc of (compose.include || [])) {
50
+ n += Array.isArray(inc.concept) ? inc.concept.length : 0;
51
+ }
52
+ for (const exc of (compose.exclude || [])) {
53
+ n += Array.isArray(exc.concept) ? exc.concept.length : 0;
54
+ }
55
+ }
56
+ if (jsonObj && jsonObj.expansion) {
57
+ n += ValueSet._countContains(jsonObj.expansion.contains);
58
+ }
59
+ return n;
60
+ }
61
+
62
+ static _countContains(items) {
63
+ if (!Array.isArray(items)) {
64
+ return 0;
65
+ }
66
+ let n = 0;
67
+ for (const it of items) {
68
+ n++;
69
+ if (it && it.contains) {
70
+ n += ValueSet._countContains(it.contains);
71
+ }
72
+ }
73
+ return n;
28
74
  }
29
75
 
30
76
  /**
@@ -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.