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/CHANGELOG.md +31 -0
- package/package.json +1 -1
- package/root-bare-template.html +58 -9775
- package/translations/Messages.properties +2 -2
- package/tx/cs/cs-cs.js +32 -5
- package/tx/importers/import-sct.module.js +167 -79
- package/tx/library/extensions.js +7 -1
- package/tx/library/renderer.js +11 -2
- package/tx/library.js +3 -0
- package/tx/vs/vs-database.js +213 -92
- package/tx/vs/vs-vsac.js +118 -50
- package/tx/workers/validate.js +1 -1
- package/tx/xversion/xv-valueset.js +28 -8
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:
|
|
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
|
-
//
|
|
74
|
-
|
|
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,
|
|
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
|
-
*
|
|
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<
|
|
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
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 '
|
|
640
|
+
`SELECT 'event' AS kind,
|
|
596
641
|
url,
|
|
597
642
|
version,
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
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
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
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
|
-
|
|
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
|
|
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
|
}
|
package/tx/workers/validate.js
CHANGED
|
@@ -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
|
-
|
|
74
|
-
|
|
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
|
-
|
|
89
|
-
|
|
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
|
-
|
|
139
|
-
|
|
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
|
-
|
|
154
|
-
|
|
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);
|