fhirsmith 0.7.6 → 0.8.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.
Files changed (57) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +5 -1
  3. package/library/languages.js +10 -0
  4. package/package.json +1 -1
  5. package/packages/package-crawler.js +2 -2
  6. package/publisher/publisher.js +1 -1
  7. package/registry/registry.js +2 -2
  8. package/root-bare-template.html +1 -2
  9. package/security.md +3 -0
  10. package/server.js +100 -70
  11. package/stats.js +37 -6
  12. package/tx/cs/cs-api.js +8 -4
  13. package/tx/cs/cs-loinc.js +14 -2
  14. package/tx/cs/cs-omop.js +5 -3
  15. package/tx/cs/cs-rxnorm.js +18 -16
  16. package/tx/cs/cs-snomed.js +279 -6
  17. package/tx/data/cpt-fragment.db +0 -0
  18. package/tx/data/cs-de.json +186 -0
  19. package/tx/data/cs-extensions.json +92 -0
  20. package/tx/data/cs-simple.json +130 -0
  21. package/tx/data/cs-supplement.json +78 -0
  22. package/tx/data/lang.dat +49180 -0
  23. package/tx/data/languages.csv +191 -0
  24. package/tx/data/loinc-subset.txt +75 -0
  25. package/tx/data/omop-fragment.db +0 -0
  26. package/tx/data/readme.md +43 -0
  27. package/tx/data/regions.csv +273 -0
  28. package/tx/data/rxnorm-subset.txt +22 -0
  29. package/tx/data/snomed-subset.txt +47 -0
  30. package/tx/data/ucum-essence.xml +2059 -0
  31. package/tx/html/dash-metrics.liquid +147 -0
  32. package/tx/importers/import-rxnorm.module.js +4 -30
  33. package/tx/library/canonical-resource.js +8 -0
  34. package/tx/library/conceptmap.js +29 -1
  35. package/tx/library/designations.js +4 -8
  36. package/tx/library/extensions.js +4 -3
  37. package/tx/library/renderer.js +9 -9
  38. package/tx/ocl/cm-ocl.cjs +185 -65
  39. package/tx/ocl/cs-ocl.cjs +69 -50
  40. package/tx/ocl/jobs/background-queue.cjs +0 -8
  41. package/tx/ocl/mappers/concept-mapper.cjs +13 -3
  42. package/tx/ocl/shared/patches.cjs +1 -0
  43. package/tx/ocl/vs-ocl.cjs +137 -157
  44. package/tx/operation-context.js +3 -3
  45. package/tx/params.js +2 -2
  46. package/tx/provider.js +6 -3
  47. package/tx/sct/structures.js +6 -1
  48. package/tx/tx.fhir.org.yml +1 -1
  49. package/tx/vs/vs-database.js +107 -23
  50. package/tx/vs/vs-vsac.js +66 -19
  51. package/tx/workers/expand.js +10 -10
  52. package/tx/workers/related.js +2 -2
  53. package/tx/workers/search.js +2 -1
  54. package/tx/workers/translate.js +222 -33
  55. package/tx/workers/validate.js +13 -13
  56. package/tx/xversion/xv-parameters.js +54 -1
  57. package/xig/xig.js +171 -9
@@ -33,12 +33,31 @@ class ValueSetDatabase {
33
33
  db.all("PRAGMA table_info(valuesets)", [], (err, cols) => {
34
34
  if (err) { reject(err); return; }
35
35
  const hasCol = cols.some(c => c.name === 'date_first_seen');
36
- if (hasCol) { resolve(); return; }
37
- db.run(
38
- "ALTER TABLE valuesets ADD COLUMN date_first_seen INTEGER DEFAULT 0",
39
- [],
40
- (err) => err ? reject(err) : resolve()
41
- );
36
+ const migrations = [];
37
+ if (!hasCol) {
38
+ migrations.push(new Promise((res, rej) => {
39
+ db.run(
40
+ "ALTER TABLE valuesets ADD COLUMN date_first_seen INTEGER DEFAULT 0",
41
+ [],
42
+ (err) => err ? rej(err) : res()
43
+ );
44
+ }));
45
+ }
46
+ // Ensure vsac_runs table exists
47
+ migrations.push(new Promise((res, rej) => {
48
+ db.run(`
49
+ CREATE TABLE IF NOT EXISTS vsac_runs (
50
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
51
+ started_at INTEGER NOT NULL,
52
+ finished_at INTEGER,
53
+ status TEXT NOT NULL DEFAULT 'running',
54
+ error_message TEXT,
55
+ total_fetched INTEGER,
56
+ total_new INTEGER
57
+ )
58
+ `, [], (err) => err ? rej(err) : res());
59
+ }));
60
+ Promise.all(migrations).then(() => resolve()).catch(reject);
42
61
  });
43
62
  });
44
63
  }
@@ -204,6 +223,19 @@ class ValueSetDatabase {
204
223
  )
205
224
  `);
206
225
 
226
+ // Run tracking table
227
+ db.run(`
228
+ CREATE TABLE vsac_runs (
229
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
230
+ started_at INTEGER NOT NULL,
231
+ finished_at INTEGER,
232
+ status TEXT NOT NULL DEFAULT 'running',
233
+ error_message TEXT,
234
+ total_fetched INTEGER,
235
+ total_new INTEGER
236
+ )
237
+ `);
238
+
207
239
  // Create indexes for better search performance
208
240
  db.run('CREATE INDEX idx_valuesets_url ON valuesets(url, version)');
209
241
  db.run('CREATE INDEX idx_valuesets_version ON valuesets(version)');
@@ -231,6 +263,58 @@ class ValueSetDatabase {
231
263
  });
232
264
  }
233
265
 
266
+ /**
267
+ * Record the start of a VSAC sync run
268
+ * @returns {Promise<number>} The run ID
269
+ */
270
+ async startRun() {
271
+ const db = await this._getWriteConnection();
272
+ return new Promise((resolve, reject) => {
273
+ db.run(
274
+ `INSERT INTO vsac_runs (started_at, status) VALUES (strftime('%s','now'), 'running')`,
275
+ [],
276
+ function(err) { err ? reject(err) : resolve(this.lastID); }
277
+ );
278
+ });
279
+ }
280
+
281
+ /**
282
+ * Record the successful completion of a VSAC sync run
283
+ * @param {number} id - The run ID from startRun()
284
+ * @param {number} totalFetched - Total value sets fetched
285
+ * @param {number} totalNew - Number of new value sets found
286
+ * @returns {Promise<void>}
287
+ */
288
+ async finishRun(id, totalFetched, totalNew) {
289
+ const db = await this._getWriteConnection();
290
+ return new Promise((resolve, reject) => {
291
+ db.run(
292
+ `UPDATE vsac_runs SET finished_at = strftime('%s','now'), status = 'ok',
293
+ total_fetched = ?, total_new = ? WHERE id = ?`,
294
+ [totalFetched, totalNew, id],
295
+ err => err ? reject(err) : resolve()
296
+ );
297
+ });
298
+ }
299
+
300
+ /**
301
+ * Record a failed VSAC sync run
302
+ * @param {number} id - The run ID from startRun()
303
+ * @param {string} errorMessage - The error message
304
+ * @returns {Promise<void>}
305
+ */
306
+ async failRun(id, errorMessage) {
307
+ const db = await this._getWriteConnection();
308
+ return new Promise((resolve, reject) => {
309
+ db.run(
310
+ `UPDATE vsac_runs SET finished_at = strftime('%s','now'), status = 'error',
311
+ error_message = ? WHERE id = ?`,
312
+ [errorMessage, id],
313
+ err => err ? reject(err) : resolve()
314
+ );
315
+ });
316
+ }
317
+
234
318
  /**
235
319
  * Insert or update a single ValueSet in the database
236
320
  * @param {Object} valueSet - The ValueSet resource
@@ -270,23 +354,23 @@ class ValueSetDatabase {
270
354
 
271
355
  db.run(`
272
356
  INSERT INTO valuesets (
273
- id, url, version, date, description, effectivePeriod_start, effectivePeriod_end,
274
- expansion_identifier, name, publisher, status, title, content, last_seen, date_first_seen
275
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now'))
276
- ON CONFLICT(id) DO UPDATE SET
277
- url=excluded.url,
278
- version=excluded.version,
279
- date=excluded.date,
280
- description=excluded.description,
281
- effectivePeriod_start=excluded.effectivePeriod_start,
282
- effectivePeriod_end=excluded.effectivePeriod_end,
283
- expansion_identifier=excluded.expansion_identifier,
284
- name=excluded.name,
285
- publisher=excluded.publisher,
286
- status=excluded.status,
287
- title=excluded.title,
288
- content=excluded.content,
289
- last_seen=strftime('%s', 'now')
357
+ id, url, version, date, description, effectivePeriod_start, effectivePeriod_end,
358
+ expansion_identifier, name, publisher, status, title, content, last_seen, date_first_seen
359
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now'))
360
+ ON CONFLICT(id) DO UPDATE SET
361
+ url=excluded.url,
362
+ version=excluded.version,
363
+ date=excluded.date,
364
+ description=excluded.description,
365
+ effectivePeriod_start=excluded.effectivePeriod_start,
366
+ effectivePeriod_end=excluded.effectivePeriod_end,
367
+ expansion_identifier=excluded.expansion_identifier,
368
+ name=excluded.name,
369
+ publisher=excluded.publisher,
370
+ status=excluded.status,
371
+ title=excluded.title,
372
+ content=excluded.content,
373
+ last_seen=strftime('%s', 'now')
290
374
  `, [
291
375
  valueSet.id,
292
376
  valueSet.url,
package/tx/vs/vs-vsac.js CHANGED
@@ -98,6 +98,7 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
98
98
  try {
99
99
  await this.refreshValueSets();
100
100
  } catch (error) {
101
+ debugLog(error);
101
102
  this.log.error(error, 'Error during scheduled refresh:');
102
103
  }
103
104
  }, intervalMs);
@@ -126,6 +127,7 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
126
127
  this.queue = [];
127
128
 
128
129
  this.isRefreshing = true;
130
+ const runId = await this.database.startRun();
129
131
 
130
132
  try {
131
133
  // phase 1: list all value sets
@@ -202,10 +204,15 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
202
204
 
203
205
  // Reload map with fresh data
204
206
  await this._reloadMap();
205
- console.log(`VSAC refresh completed. Total: ${tracking.totalFetched} ValueSets, Deleted: ${tracking.deletedCount}`);
207
+ let msg = `VSAC refresh completed. Total: ${tracking.totalFetched} ValueSets, Deleted: ${tracking.deletedCount}`;
208
+ this.stats.taskDone('VSAC Sync', msg);
209
+ console.log(msg);
210
+
211
+ await this.database.finishRun(runId, tracking.totalFetched, tracking.totalNew);
206
212
  } catch (error) {
207
213
  debugLog(error, 'Error during VSAC refresh:');
208
- this.stats.task('VSAC Sync', `Error (${error.message})`);
214
+ this.stats.taskError('VSAC Sync', `Error (${error.message})`);
215
+ await this.database.failRun(runId, error.message);
209
216
  throw error;
210
217
  } finally {
211
218
  this.isRefreshing = false;
@@ -265,7 +272,6 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
265
272
  }
266
273
  }
267
274
 
268
-
269
275
  /**
270
276
  * Fetch a FHIR Bundle from the server
271
277
  * @param {string} url - Relative URL to fetch
@@ -511,7 +517,6 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
511
517
  let logMsg = `VSAC (${tracking.count} of ${length}) ${q}: ${vcount} versions`;
512
518
  console.log(logMsg);
513
519
  this.stats.task('VSAC Sync', logMsg);
514
-
515
520
  }
516
521
 
517
522
  name() {
@@ -523,35 +528,77 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
523
528
  }
524
529
 
525
530
  async info() {
531
+ const escape = require('escape-html');
526
532
  const db = await this.database._getReadConnection();
533
+
527
534
  const rows = await new Promise((resolve, reject) => {
528
535
  db.all(
529
- `SELECT url, version, date_first_seen
536
+ `SELECT 'vs' AS kind,
537
+ url,
538
+ version,
539
+ date_first_seen AS ts,
540
+ NULL AS status,
541
+ NULL AS error_message,
542
+ NULL AS finished_at,
543
+ NULL AS total_fetched,
544
+ NULL AS total_new
530
545
  FROM valuesets
531
546
  WHERE date_first_seen > 0
532
- ORDER BY date_first_seen DESC
533
- LIMIT 100`,
547
+ UNION ALL
548
+ SELECT 'run' AS kind,
549
+ NULL,
550
+ NULL,
551
+ started_at AS ts,
552
+ status,
553
+ error_message,
554
+ finished_at,
555
+ total_fetched,
556
+ total_new
557
+ FROM vsac_runs
558
+ ORDER BY ts DESC
559
+ LIMIT 200`,
534
560
  [],
535
561
  (err, rows) => err ? reject(err) : resolve(rows)
536
562
  );
537
563
  });
538
564
 
539
- const escape = require('escape-html');
540
- let html = '<h3>Recently Value Sets Added to VSAC</h3>';
541
- html += '<p>The last ' + rows.length + ' value sets found from VSAC, most recent first.</p>';
565
+ const fmt = ts => ts
566
+ ? new Date(ts * 1000).toISOString().replace('T', ' ').substring(0, 19) + ' UTC'
567
+ : '';
568
+
569
+ let html = '<h3>VSAC Sync History</h3>';
542
570
  html += '<table class="grid">';
543
- html += '<thead><tr><th>URL</th><th>Version</th><th>Date Observed</th></tr></thead>';
571
+ html += '<thead><tr><th>Time</th><th>Event</th><th>Detail</th></tr></thead>';
544
572
  html += '<tbody>';
573
+
545
574
  for (const row of rows) {
546
- const date = row.date_first_seen
547
- ? new Date(row.date_first_seen * 1000).toISOString().replace('T', ' ').substring(0, 19) + ' UTC'
548
- : 'unknown';
549
- html += '<tr>';
550
- html += `<td>${escape(row.url || '')}</td>`;
551
- html += `<td>${escape(row.version || '')}</td>`;
552
- html += `<td>${escape(date)}</td>`;
553
- html += '</tr>';
575
+ if (row.kind === 'run') {
576
+ const duration = row.finished_at ? `${row.finished_at - row.ts}s` : 'in progress';
577
+ let detail, colour;
578
+ if (row.status === 'ok') {
579
+ detail = `${row.total_fetched} fetched, ${row.total_new} new, ${duration}`;
580
+ colour = 'green';
581
+ } else if (row.status === 'error') {
582
+ detail = `Failed: ${escape(row.error_message || '')} (${duration})`;
583
+ colour = 'red';
584
+ } else {
585
+ detail = `Running... (started ${fmt(row.ts)})`;
586
+ colour = 'orange';
587
+ }
588
+ html += `<tr style="background:#f0f0f0">`;
589
+ html += `<td>${escape(fmt(row.ts))}</td>`;
590
+ html += `<td><strong style="color:${colour}">Sync run</strong></td>`;
591
+ html += `<td>${detail}</td>`;
592
+ html += `</tr>`;
593
+ } else {
594
+ html += `<tr>`;
595
+ html += `<td>${escape(fmt(row.ts))}</td>`;
596
+ html += `<td>New value set</td>`;
597
+ html += `<td>${escape(row.url || '')}#${escape(row.version || '')}</td>`;
598
+ html += `</tr>`;
599
+ }
554
600
  }
601
+
555
602
  html += '</tbody></table>';
556
603
  return html;
557
604
  }
@@ -604,7 +604,7 @@ class ValueSetExpander {
604
604
 
605
605
  async checkSource(cset, exp, filter, srcURL, ts, vsInfo) {
606
606
  this.worker.deadCheck('checkSource');
607
- Extensions.checkNoModifiers(cset, 'ValueSetExpander.checkSource', 'set');
607
+ Extensions.checkNoModifiers(cset, 'ValueSetExpander.checkSource', 'set', srcURL);
608
608
  let imp = false;
609
609
  for (const u of cset.valueSet || []) {
610
610
  this.worker.deadCheck('checkSource');
@@ -682,7 +682,7 @@ class ValueSetExpander {
682
682
  this.worker.deadCheck('processCodes#1');
683
683
  const valueSets = [];
684
684
 
685
- Extensions.checkNoModifiers(cset, 'ValueSetExpander.processCodes', 'set');
685
+ Extensions.checkNoModifiers(cset, 'ValueSetExpander.processCodes', 'set', vsSrc.vurl);
686
686
 
687
687
  if (cset.valueSet || cset.concept || (cset.filter || []).length > 1) {
688
688
  this.canBeHierarchy = false;
@@ -792,7 +792,7 @@ class ValueSetExpander {
792
792
  for (const cc of cset.concept) {
793
793
  this.worker.deadCheck('processCodes#3');
794
794
  cds.clear();
795
- Extensions.checkNoModifiers(cc, 'ValueSetExpander.processCodes', 'set concept reference');
795
+ Extensions.checkNoModifiers(cc, 'ValueSetExpander.processCodes', 'set concept reference', vsSrc.vurl);
796
796
  const cctxt = await cs.locate(cc.code, this.allAltCodes);
797
797
  if (cctxt && cctxt.context && (!this.params.activeOnly || !await cs.isInactive(cctxt.context)) && await this.passesFilters(cs, cctxt.context, prep, filters, 0)) {
798
798
  await this.listDisplaysFromProvider(cds, cs, cctxt.context);
@@ -834,7 +834,7 @@ class ValueSetExpander {
834
834
  if (!fc.value) {
835
835
  throw new Issue('error', 'invalid', path + ".filter[" + i + "]", 'UNABLE_TO_HANDLE_SYSTEM_FILTER_WITH_NO_VALUE', this.worker.i18n.translate('UNABLE_TO_HANDLE_SYSTEM_FILTER_WITH_NO_VALUE', this.params.httpLanguages, [cs.system(), fc.property, fc.op]), 'vs-invalid', 400);
836
836
  }
837
- Extensions.checkNoModifiers(fc, 'ValueSetExpander.processCodes', 'filter');
837
+ Extensions.checkNoModifiers(fc, 'ValueSetExpander.processCodes', 'filter', vsSrc.vurl);
838
838
  await cs.filter(prep, fc.property, fc.op, fc.value);
839
839
  }
840
840
 
@@ -891,7 +891,7 @@ class ValueSetExpander {
891
891
  this.worker.deadCheck('processCodes#1');
892
892
  const valueSets = [];
893
893
 
894
- Extensions.checkNoModifiers(cset, 'ValueSetExpander.processCodes', 'set');
894
+ Extensions.checkNoModifiers(cset, 'ValueSetExpander.processCodes', 'set', vsSrc.vurl);
895
895
 
896
896
  if (cset.valueSet || cset.concept || (cset.filter || []).length > 1) {
897
897
  this.canBeHierarchy = false;
@@ -978,7 +978,7 @@ class ValueSetExpander {
978
978
  for (const cc of cset.concept) {
979
979
  this.worker.deadCheck('processCodes#3');
980
980
  cds.clear();
981
- Extensions.checkNoModifiers(cc, 'ValueSetExpander.processCodes', 'set concept reference');
981
+ Extensions.checkNoModifiers(cc, 'ValueSetExpander.processCodes', 'set concept reference', vsSrc.vurl);
982
982
  const cctxt = await cs.locate(cc.code, this.allAltCodes);
983
983
  if (cctxt && cctxt.context && (!this.params.activeOnly || !await cs.isInactive(cctxt)) && await this.passesFilters(cs, cctxt, prep, filters, 0)) {
984
984
  if (filter.passesDesignations(cds) || filter.passes(cc.code)) {
@@ -1007,7 +1007,7 @@ class ValueSetExpander {
1007
1007
 
1008
1008
  for (let fc of cset.filter) {
1009
1009
  this.worker.deadCheck('processCodes#4a');
1010
- Extensions.checkNoModifiers(fc, 'ValueSetExpander.processCodes', 'filter');
1010
+ Extensions.checkNoModifiers(fc, 'ValueSetExpander.processCodes', 'filter', vsSrc.vurl);
1011
1011
  await cs.filter(prep, fc.property, fc.op, fc.value);
1012
1012
  }
1013
1013
 
@@ -1157,8 +1157,8 @@ class ValueSetExpander {
1157
1157
  this.totalStatus = 'uninitialised';
1158
1158
  this.total = 0;
1159
1159
 
1160
- Extensions.checkNoImplicitRules(source,'ValueSetExpander.Expand', 'ValueSet');
1161
- Extensions.checkNoModifiers(source,'ValueSetExpander.Expand', 'ValueSet');
1160
+ Extensions.checkNoImplicitRules(source,'ValueSetExpander.Expand', 'ValueSet', source.vurl);
1161
+ Extensions.checkNoModifiers(source,'ValueSetExpander.Expand', 'ValueSet', source.vurl);
1162
1162
  this.worker.seeValueSet(source, this.params);
1163
1163
  this.valueSet = source;
1164
1164
 
@@ -1272,7 +1272,7 @@ class ValueSetExpander {
1272
1272
 
1273
1273
  let vsInfo = this.scanValueSet(source.jsonObj.compose);
1274
1274
  try {
1275
- if (source.jsonObj.compose && Extensions.checkNoModifiers(source.jsonObj.compose, 'ValueSetExpander.Expand', 'compose')
1275
+ if (source.jsonObj.compose && Extensions.checkNoModifiers(source.jsonObj.compose, 'ValueSetExpander.Expand', 'compose', source.vurl)
1276
1276
  && this.worker.checkNoLockedDate(source.url, source.jsonObj.compose)) {
1277
1277
  await this.handleCompose(source, filter, exp, notClosed, vsInfo);
1278
1278
  }
@@ -205,12 +205,12 @@ class RelatedWorker extends TerminologyWorker {
205
205
  if (!thisC) {
206
206
  return this.makeOutcome("indeterminate", `The ValueSet ${thisVS.vurl} has no compose`);
207
207
  }
208
- Extensions.checkNoModifiers(thisC, 'RelatedWorker.doRelated', 'compose')
208
+ Extensions.checkNoModifiers(thisC, 'RelatedWorker.doRelated', 'compose', thisVS.vurl)
209
209
  this.checkNoLockedDate(thisVS.vurl, thisC);
210
210
  if (!otherC) {
211
211
  return this.makeOutcome("indeterminate", `The ValueSet ${otherVS.vurl} has no compose`);
212
212
  }
213
- Extensions.checkNoModifiers(otherC, 'RelatedWorker.doRelated', 'compose')
213
+ Extensions.checkNoModifiers(otherC, 'RelatedWorker.doRelated', 'compose', otherVS.vurl)
214
214
  this.checkNoLockedDate(otherVS.vurl, otherC);
215
215
 
216
216
  let systems = new Map(); // tracks whether they are version dependent or not
@@ -35,7 +35,8 @@ class SearchWorker extends TerminologyWorker {
35
35
  '_offset', '_count', '_elements', '_sort', '_summary', '_total', '_format',
36
36
  'url', 'version', 'content-mode', 'date', 'description',
37
37
  'supplements', 'identifier', 'jurisdiction', 'name',
38
- 'publisher', 'status', 'system', 'title', 'text'
38
+ 'publisher', 'status', 'system', 'title', 'text',
39
+ 'source-system', 'target-system'
39
40
  ];
40
41
 
41
42
  // Summary elements for _summary=true (marked elements per resource type)