fhirsmith 0.9.0 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/tx/vs/vs-vsac.js CHANGED
@@ -1,4 +1,5 @@
1
1
  const path = require('path');
2
+ const crypto = require('crypto');
2
3
  const axios = require('axios');
3
4
  const { AbstractValueSetProvider } = require('./vs-api');
4
5
  const { ValueSetDatabase } = require('./vs-database');
@@ -11,12 +12,15 @@ const {debugLog} = require("../operation-context");
11
12
  * Fetches and caches ValueSets from the NLM VSAC FHIR server
12
13
  */
13
14
  class VSACValueSetProvider extends AbstractValueSetProvider {
15
+ SYNC_AT_START_UP = false;
16
+
14
17
  /**
15
18
  * @param {Object} config - Configuration object
16
19
  * @param {string} config.apiKey - API key for VSAC authentication
17
20
  * @param {string} config.cacheFolder - Local folder for cached database
18
21
  * @param {number} [config.refreshIntervalHours=24] - Hours between refresh scans
19
22
  * @param {string} [config.baseUrl='http://cts.nlm.nih.gov/fhir'] - Base URL for VSAC FHIR server
23
+ * @param {number} [config.timeoutMs=120000] - HTTP request timeout in milliseconds
20
24
  */
21
25
  constructor(config, stats) {
22
26
  super();
@@ -43,7 +47,7 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
43
47
  const authString = Buffer.from(`apikey:${this.apiKey}`).toString('base64');
44
48
  this.httpClient = axios.create({
45
49
  baseURL: this.baseUrl,
46
- timeout: 30000,
50
+ timeout: config.timeoutMs || 120000,
47
51
  headers: {
48
52
  'Accept': 'application/fhir+json',
49
53
  'User-Agent': 'FHIR-ValueSet-Provider/1.0',
@@ -70,12 +74,11 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
70
74
  if (!(await this.database.exists())) {
71
75
  await this.database.create();
72
76
  } else {
73
- // Ensure schema is up to date (e.g. date_first_seen column added after initial deploy)
74
- await this.database._migrateIfNeeded(await this.database._getWriteConnection());
75
- // Load existing data
77
+ // Schema migrations are applied lazily by the database layer on first
78
+ // connection. Just load existing data.
76
79
  await this._reloadMap();
77
80
  }
78
- if (this.valueSetMap.size == 0) {
81
+ if (this.SYNC_AT_START_UP || this.valueSetMap.size == 0) {
79
82
  await this.refreshValueSets();
80
83
  }
81
84
  // Start periodic refresh
@@ -181,11 +184,11 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
181
184
  // deduplicate the queue
182
185
  this.queue = [...new Set(this.queue)];
183
186
 
184
- let tracking = { totalFetched: 0, totalNew: 0, count: 0, newCount : 0 };
187
+ let tracking = { totalFetched: 0, totalNew: 0, totalUpdated: 0, count: 0, newCount : 0 };
185
188
  // phase 2: query for history & content
186
189
  this.requeue = [];
187
190
  for (let q of this.queue) {
188
- this.stats.task('VSAC History for '+q, `running (${tracking.totalFetched} fetched, ${tracking.totalNew} new)`);
191
+ this.stats.task('VSAC History for '+q, `running (${tracking.totalFetched} fetched, ${tracking.totalNew} new, ${tracking.totalUpdated} updated)`);
189
192
  try {
190
193
  await this.processContentAndHistory(q, tracking, this.queue.length);
191
194
  } catch (error) {
@@ -193,29 +196,27 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
193
196
  debugLog(error);
194
197
  this.stats.task('VSAC Sync', error.message);
195
198
  }
196
- // `running (${totalFetched} fetched, ${totalNew} new)`)
197
199
  tracking.count++;
198
200
  }
199
201
  console.log("Requeue");
200
202
  for (let q of this.requeue) {
201
- this.stats.task('VSAC History for '+q, `running (${tracking.totalFetched} fetched, ${tracking.totalNew} new)`);
203
+ this.stats.task('VSAC History for '+q, `running (${tracking.totalFetched} fetched, ${tracking.totalNew} new, ${tracking.totalUpdated} updated)`);
202
204
  try {
203
205
  await this.processContentAndHistory(q, tracking, this.requeue.length);
204
206
  } catch (error) {
205
207
  debugLog(error);
206
208
  this.stats.task('VSAC Sync', error.message);
207
209
  }
208
- // `running (${totalFetched} fetched, ${totalNew} new)`)
209
210
  tracking.count++;
210
211
  }
211
212
 
212
213
  // Reload map with fresh data
213
214
  await this._reloadMap();
214
- let msg = `VSAC refresh completed. Total: ${tracking.totalFetched} ValueSets, Deleted: ${tracking.deletedCount}`;
215
+ let msg = `VSAC refresh completed. Total: ${tracking.totalFetched} ValueSets, New: ${tracking.totalNew}, Updated: ${tracking.totalUpdated}`;
215
216
  this.stats.taskDone('VSAC Sync', msg);
216
217
  console.log(msg);
217
218
 
218
- await this.database.finishRun(runId, tracking.totalFetched, tracking.totalNew);
219
+ await this.database.finishRun(runId, tracking.totalFetched, tracking.totalNew, tracking.totalUpdated);
219
220
  } catch (error) {
220
221
  debugLog(error, 'Error during VSAC refresh:');
221
222
  this.stats.taskError('VSAC Sync', `Error (${error.message})`);
@@ -227,30 +228,71 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
227
228
  }
228
229
 
229
230
  /**
230
- * Insert multiple ValueSets in a batch operation
231
+ * Compute a SHA-256 hash of the ValueSet content for change detection.
232
+ * @param {Object} vs - The ValueSet resource (plain JSON object)
233
+ * @returns {string} hex-encoded SHA-256
234
+ * @private
235
+ */
236
+ _hashValueSet(vs) {
237
+ return crypto.createHash('sha256').update(JSON.stringify(vs)).digest('hex');
238
+ }
239
+
240
+ /**
241
+ * Insert multiple ValueSets in a batch operation.
242
+ * For each value set: if url|version is already known, compare content hashes.
243
+ * - hash unchanged -> touch last_seen only (seeValueSet)
244
+ * - hash changed -> upsert and record an 'updated' event
245
+ * - not seen before -> upsert and record a 'new' event
231
246
  * @param {Array<Object>} valueSets - Array of ValueSet resources
232
- * @returns {Promise<void>}
247
+ * @returns {Promise<{newCount: number, updatedCount: number}>}
233
248
  */
234
249
  async batchUpsertValueSets(valueSets) {
235
250
  if (valueSets.length === 0) {
236
- return;
251
+ return { newCount: 0, updatedCount: 0 };
237
252
  }
238
253
 
239
- let count = 0;
254
+ let newCount = 0;
255
+ let updatedCount = 0;
256
+
240
257
  // Process sequentially to avoid database locking
241
258
  for (const valueSet of valueSets) {
242
- let key = valueSet.url+"|"+valueSet.version;
243
- let vs = this.valueSetMap.get(key);
244
- if (vs) {
245
- // we've seen this before, and maybe fetched it's history, so just update
246
- // the timestamp
247
- await this.database.seeValueSet(valueSet);
259
+ const key = valueSet.url+"|"+valueSet.version;
260
+ const existing = this.valueSetMap.get(key);
261
+ const newHash = this._hashValueSet(valueSet);
262
+
263
+ if (existing) {
264
+ // We've seen this url|version before. Decide whether the content
265
+ // has actually changed by comparing hashes.
266
+ //
267
+ // Note: _reloadMap() mutates the in-memory jsonObj (strips inc.version
268
+ // from compose.include/exclude), so we cannot reliably recompute a
269
+ // hash from existing.jsonObj — it would not match the hash of the
270
+ // original unmutated JSON we stored. For rows predating this feature
271
+ // (content_hash NULL), we defer update detection until the next cycle:
272
+ // the upsert below runs only when hashes differ, so on the *next*
273
+ // sync after migration we'll have a proper baseline.
274
+ if (existing.contentHash && existing.contentHash === newHash) {
275
+ // No change - just touch last_seen
276
+ await this.database.seeValueSet(valueSet);
277
+ } else if (!existing.contentHash) {
278
+ // Legacy row without a stored hash - backfill the hash silently
279
+ // without emitting a spurious 'updated' event. We do a lightweight
280
+ // touch + hash update rather than a full upsert+event.
281
+ await this.database.seeValueSet(valueSet);
282
+ await this.database.setContentHash(valueSet.id, newHash);
283
+ } else {
284
+ // Content has changed - treat as update
285
+ await this.database.upsertValueSet(valueSet, newHash);
286
+ await this.database.recordEvent('updated', valueSet.url, valueSet.version);
287
+ updatedCount++;
288
+ }
248
289
  } else {
249
- await this.database.upsertValueSet(valueSet);
250
- count++;
290
+ await this.database.upsertValueSet(valueSet, newHash);
291
+ await this.database.recordEvent('new', valueSet.url, valueSet.version);
292
+ newCount++;
251
293
  }
252
294
  }
253
- return count;
295
+ return { newCount, updatedCount };
254
296
  }
255
297
 
256
298
  /**
@@ -510,18 +552,21 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
510
552
  const bundle = await this._fetchBundle(url);
511
553
 
512
554
  let vcount = 0;
555
+ let perRun = { newCount: 0, updatedCount: 0 };
513
556
  if (bundle.entry && bundle.entry.length > 0) {
514
557
  // Extract ValueSets from bundle entries
515
558
  const valueSets = bundle.entry
516
559
  .filter(entry => entry.resource && entry.resource.resourceType === 'ValueSet')
517
560
  .map(entry => entry.resource);
518
561
  if (valueSets.length > 0) {
519
- tracking.totalNew = tracking.totalNew + await this.batchUpsertValueSets(valueSets);
562
+ perRun = await this.batchUpsertValueSets(valueSets);
563
+ tracking.totalNew += perRun.newCount;
564
+ tracking.totalUpdated += perRun.updatedCount;
520
565
  tracking.totalFetched += valueSets.length;
521
566
  vcount = valueSets.length;
522
567
  }
523
568
  }
524
- let logMsg = `VSAC (${tracking.count} of ${length}) ${q}: ${vcount} versions`;
569
+ let logMsg = `VSAC (${tracking.count} of ${length}) ${q}: ${vcount} versions (${perRun.newCount} new, ${perRun.updatedCount} updated)`;
525
570
  console.log(logMsg);
526
571
  this.stats.task('VSAC Sync', logMsg);
527
572
  }
@@ -592,30 +637,33 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
592
637
 
593
638
  const rows = await new Promise((resolve, reject) => {
594
639
  db.all(
595
- `SELECT 'vs' AS kind,
640
+ `SELECT 'event' AS kind,
596
641
  url,
597
642
  version,
598
- date_first_seen AS ts,
599
- NULL AS status,
600
- NULL AS error_message,
601
- NULL AS finished_at,
602
- NULL AS total_fetched,
603
- NULL AS total_new
604
- FROM valuesets
605
- WHERE date_first_seen > 0
643
+ timestamp AS ts,
644
+ event_type,
645
+ NULL AS status,
646
+ NULL AS error_message,
647
+ NULL AS finished_at,
648
+ NULL AS total_fetched,
649
+ NULL AS total_new,
650
+ NULL AS total_updated
651
+ FROM vsac_events
606
652
  UNION ALL
607
- SELECT 'run' AS kind,
608
- NULL,
609
- NULL,
610
- started_at AS ts,
611
- status,
612
- error_message,
613
- finished_at,
614
- total_fetched,
615
- total_new
616
- FROM vsac_runs
617
- ORDER BY ts DESC
618
- LIMIT 200`,
653
+ SELECT 'run' AS kind,
654
+ NULL,
655
+ NULL,
656
+ started_at AS ts,
657
+ NULL AS event_type,
658
+ status,
659
+ error_message,
660
+ finished_at,
661
+ total_fetched,
662
+ total_new,
663
+ total_updated
664
+ FROM vsac_runs
665
+ ORDER BY ts DESC
666
+ LIMIT 200`,
619
667
  [],
620
668
  (err, rows) => err ? reject(err) : resolve(rows)
621
669
  );
@@ -635,7 +683,8 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
635
683
  const duration = row.finished_at ? `${row.finished_at - row.ts}s` : 'in progress';
636
684
  let detail, colour;
637
685
  if (row.status === 'ok') {
638
- detail = `${row.total_fetched} fetched, ${row.total_new} new, ${duration}`;
686
+ const updated = row.total_updated != null ? `, ${row.total_updated} updated` : '';
687
+ detail = `${row.total_fetched} fetched, ${row.total_new} new${updated}, ${duration}`;
639
688
  colour = 'green';
640
689
  } else if (row.status === 'error') {
641
690
  detail = `Failed: ${escape(row.error_message || '')} (${duration})`;
@@ -650,9 +699,28 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
650
699
  html += `<td>${detail}</td>`;
651
700
  html += `</tr>`;
652
701
  } else {
702
+ // Event row: 'new', 'updated', or 'deleted'
703
+ let label, colour;
704
+ switch (row.event_type) {
705
+ case 'new':
706
+ label = 'New value set';
707
+ colour = 'green';
708
+ break;
709
+ case 'updated':
710
+ label = 'Updated value set';
711
+ colour = 'blue';
712
+ break;
713
+ case 'deleted':
714
+ label = 'Deleted value set';
715
+ colour = 'red';
716
+ break;
717
+ default:
718
+ label = escape(row.event_type || 'Event');
719
+ colour = 'black';
720
+ }
653
721
  html += `<tr>`;
654
722
  html += `<td>${escape(fmt(row.ts))}</td>`;
655
- html += `<td>New value set</td>`;
723
+ html += `<td><span style="color:${colour}">${label}</span></td>`;
656
724
  html += `<td>${escape(row.url || '')}#${escape(row.version || '')}</td>`;
657
725
  html += `</tr>`;
658
726
  }
@@ -839,7 +839,7 @@ class ValueSetChecker {
839
839
  } else {
840
840
  bAdd = !unknownSystems.has(system + '|' + version);
841
841
  if (bAdd) {
842
- let vl = await this.listVersions(system);
842
+ let vl = await this.worker.listVersions(system);
843
843
  if (vl.length == 0) {
844
844
  mid = 'UNKNOWN_CODESYSTEM_VERSION_NONE';
845
845
  vn = system;
@@ -1,5 +1,6 @@
1
1
  const {VersionUtilities} = require("../../library/version-utilities");
2
2
  const {getValueName} = require("../../library/utilities");
3
+ const {Extensions} = require("../library/extensions");
3
4
 
4
5
  /**
5
6
  * Converts input ValueSet to R5 format (modifies input object for performance)
@@ -13,6 +14,12 @@ function valueSetToR5(jsonObj, sourceVersion) {
13
14
  if (VersionUtilities.isR5Ver(sourceVersion)) {
14
15
  return jsonObj; // No conversion needed
15
16
  }
17
+ for (const inc of jsonObj.compose.include || []) {
18
+ valueSetIncludeToR5(inc);
19
+ }
20
+ for (const inc of jsonObj.compose.exclude || []) {
21
+ valueSetIncludeToR5(inc);
22
+ }
16
23
  if (VersionUtilities.isR4Ver(sourceVersion)) {
17
24
  return jsonObj; // No conversion needed
18
25
  }
@@ -26,6 +33,19 @@ function valueSetToR5(jsonObj, sourceVersion) {
26
33
  throw new Error(`Unsupported FHIR version: ${sourceVersion}`);
27
34
  }
28
35
 
36
+ function valueSetIncludeToR5(inc) {
37
+ for (const filter of inc.filter || []) {
38
+ if (filter._op) {
39
+ let code = Extensions.readString(filter._op, 'http://hl7.org/fhir/5.0/StructureDefinition/extension-ValueSet.compose.include.filter.op');
40
+ if (code) {
41
+ filter.op = code;
42
+ delete filter._op;
43
+ }
44
+ }
45
+ }
46
+ }
47
+
48
+
29
49
  /**
30
50
  * Converts R5 ValueSet to target version format (clones object first)
31
51
  * @param {Object} r5Obj - The R5 format ValueSet object
@@ -70,8 +90,8 @@ function valueSetR5ToR4(r5Obj) {
70
90
  if (include.filter && Array.isArray(include.filter)) {
71
91
  include.filter = include.filter.map(filter => {
72
92
  if (filter.op && isR5OnlyFilterOperator(filter.op)) {
73
- // Remove R5-only operators
74
- return null;
93
+ filter._op = { "extension": "http://hl7.org/fhir/5.0/StructureDefinition/extension-ValueSet.compose.include.filter.op", "valueCode": filter.op}
94
+ delete filter.op;
75
95
  }
76
96
  return filter;
77
97
  }).filter(filter => filter !== null);
@@ -85,8 +105,8 @@ function valueSetR5ToR4(r5Obj) {
85
105
  if (exclude.filter && Array.isArray(exclude.filter)) {
86
106
  exclude.filter = exclude.filter.map(filter => {
87
107
  if (filter.op && isR5OnlyFilterOperator(filter.op)) {
88
- // Remove R5-only operators
89
- return null;
108
+ filter._op = { "extension": "http://hl7.org/fhir/5.0/StructureDefinition/extension-ValueSet.compose.include.filter.op", "valueCode": filter.op}
109
+ delete filter.op;
90
110
  }
91
111
  return filter;
92
112
  }).filter(filter => filter !== null);
@@ -135,8 +155,8 @@ function valueSetR5ToR3(r5Obj) {
135
155
  if (include.filter && Array.isArray(include.filter)) {
136
156
  include.filter = include.filter.map(filter => {
137
157
  if (filter.op && !isR3CompatibleFilterOperator(filter.op)) {
138
- // Remove non-R3-compatible operators
139
- return null;
158
+ filter._op = { "extension": "http://hl7.org/fhir/5.0/StructureDefinition/extension-ValueSet.compose.include.filter.op", "valueCode": filter.op}
159
+ delete filter.op;
140
160
  }
141
161
  return filter;
142
162
  }).filter(filter => filter !== null);
@@ -150,8 +170,8 @@ function valueSetR5ToR3(r5Obj) {
150
170
  if (exclude.filter && Array.isArray(exclude.filter)) {
151
171
  exclude.filter = exclude.filter.map(filter => {
152
172
  if (filter.op && !isR3CompatibleFilterOperator(filter.op)) {
153
- // Remove non-R3-compatible operators
154
- return null;
173
+ filter._op = { "extension": "http://hl7.org/fhir/5.0/StructureDefinition/extension-ValueSet.compose.include.filter.op", "valueCode": filter.op}
174
+ delete filter.op;
155
175
  }
156
176
  return filter;
157
177
  }).filter(filter => filter !== null);