fhirsmith 0.7.6 → 0.8.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.
@@ -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
  }
@@ -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)
@@ -115,8 +115,12 @@ class TranslateWorker extends TerminologyWorker {
115
115
  let targetSystem = null;
116
116
 
117
117
  // Get the source coding
118
+ // Accept both R5 names (sourceCoding, sourceCodeableConcept, sourceCode/sourceSystem)
119
+ // and R4 names (coding, codeableConcept, code/system) as aliases
118
120
  if (params.has('sourceCoding')) {
119
121
  coding = params.get('sourceCoding');
122
+ } else if (params.has('coding')) {
123
+ coding = params.get('coding');
120
124
  } else if (params.has('sourceCodeableConcept')) {
121
125
  const cc = params.get('sourceCodeableConcept');
122
126
  if (cc.coding && cc.coding.length > 0) {
@@ -125,16 +129,23 @@ class TranslateWorker extends TerminologyWorker {
125
129
  throw new Issue('error', 'invalid', null, null,
126
130
  'sourceCodeableConcept must contain at least one coding', null, 400);
127
131
  }
128
- } else if (params.has('sourceCode')) {
129
- if (!params.has('sourceSystem')) {
132
+ } else if (params.has('codeableConcept')) {
133
+ const cc = params.get('codeableConcept');
134
+ if (cc.coding && cc.coding.length > 0) {
135
+ coding = cc.coding[0];
136
+ } else {
130
137
  throw new Issue('error', 'invalid', null, null,
131
- 'sourceSystem parameter is required when using sourceCode', null, 400);
138
+ 'codeableConcept must contain at least one coding', null, 400);
132
139
  }
133
- coding = {
134
- system: params.get('sourceSystem'),
135
- version: params.get('sourceVersion'),
136
- code: params.get('sourceCode')
137
- };
140
+ } else if (params.has('sourceCode') || params.has('code')) {
141
+ const code = params.has('sourceCode') ? params.get('sourceCode') : params.get('code');
142
+ const system = params.has('sourceSystem') ? params.get('sourceSystem') : params.get('system');
143
+ if (!system) {
144
+ throw new Issue('error', 'invalid', null, null,
145
+ 'system parameter is required when using code/sourceCode', null, 400);
146
+ }
147
+ const version = params.has('sourceVersion') ? params.get('sourceVersion') : params.get('version');
148
+ coding = { system, version, code };
138
149
  } else {
139
150
  throw new Issue('error', 'invalid', null, null,
140
151
  'Must provide sourceCode (with system), sourceCoding, or sourceCodeableConcept', null, 400);
@@ -169,7 +180,7 @@ class TranslateWorker extends TerminologyWorker {
169
180
  // If no explicit concept map, we need to find one based on source/target
170
181
  if (conceptMaps.length == 0) {
171
182
  await this.findConceptMapsInAdditionalResources(conceptMaps, coding.system, sourceScope, targetScope, targetSystem);
172
- await this.provider.findConceptMapForTranslation(this.opContext, conceptMaps, coding.system, sourceScope, targetScope, targetSystem);
183
+ await this.provider.findConceptMapForTranslation(this.opContext, conceptMaps, coding.system, sourceScope, targetScope, targetSystem, coding.code);
173
184
  if (conceptMaps.length == 0) {
174
185
  throw new Issue('error', 'not-found', null, null, 'No suitable ConceptMaps found for the specified source and target', null, 404);
175
186
  }
@@ -208,10 +219,14 @@ class TranslateWorker extends TerminologyWorker {
208
219
  txp.readParams(params.jsonObj);
209
220
 
210
221
  // Get the source coding
222
+ // Accept both R5 names (sourceCoding, sourceCodeableConcept, sourceCode)
223
+ // and R4 names (coding, codeableConcept, code) as aliases
211
224
  let coding = null;
212
225
 
213
226
  if (params.has('sourceCoding')) {
214
227
  coding = params.get('sourceCoding');
228
+ } else if (params.has('coding')) {
229
+ coding = params.get('coding');
215
230
  } else if (params.has('sourceCodeableConcept')) {
216
231
  const cc = params.get('sourceCodeableConcept');
217
232
  if (cc.coding && cc.coding.length > 0) {
@@ -220,15 +235,25 @@ class TranslateWorker extends TerminologyWorker {
220
235
  throw new Issue('error', 'invalid', null, null,
221
236
  'sourceCodeableConcept must contain at least one coding', null, 400);
222
237
  }
223
- } else if (params.has('sourceCode')) {
224
- if (!params.has('system')) {
238
+ } else if (params.has('codeableConcept')) {
239
+ const cc = params.get('codeableConcept');
240
+ if (cc.coding && cc.coding.length > 0) {
241
+ coding = cc.coding[0];
242
+ } else {
243
+ throw new Issue('error', 'invalid', null, null,
244
+ 'codeableConcept must contain at least one coding', null, 400);
245
+ }
246
+ } else if (params.has('sourceCode') || params.has('code')) {
247
+ const code = params.has('sourceCode') ? params.get('sourceCode') : params.get('code');
248
+ const system = params.has('system') ? params.get('system') : null;
249
+ if (!system) {
225
250
  throw new Issue('error', 'invalid', null, null,
226
- 'system parameter is required when using sourceCode', null, 400);
251
+ 'system parameter is required when using code/sourceCode', null, 400);
227
252
  }
228
253
  coding = {
229
- system: params.get('system'),
254
+ system,
230
255
  version: params.get('version'),
231
- code: params.get('sourceCode')
256
+ code
232
257
  };
233
258
  } else {
234
259
  throw new Issue('error', 'invalid', null, null,
@@ -1107,7 +1107,7 @@ class ValueSetChecker {
1107
1107
  codelist = !codelist ? '\'' + cc + '\'' : codelist + ', \'' + cc + '\'';
1108
1108
 
1109
1109
  if (v === false && !this.valueSet.jsonObj.internallyDefined && mode === 'codeableConcept') {
1110
- let m = this.worker.i18n.translate('None_of_the_provided_codes_are_in_the_value_set_one', this.params.HTTPLanguages, ['', this.valueSet.vurl, '\'' + cc + '\'']);
1110
+ let m = this.worker.i18n.translate('None_of_the_provided_codes_are_in_the_value_set_one', this.params.HTTPLanguages, ['', this.valueSet.vurlOrMsg, '\'' + cc + '\'']);
1111
1111
  let p = issuePath + '.coding[' + i + '].code';
1112
1112
  op.addIssue(new Issue('information', 'code-invalid', p, 'None_of_the_provided_codes_are_in_the_value_set_one', m, 'this-code-not-in-vs'));
1113
1113
  if (cause.value === 'null') {
@@ -1284,10 +1284,10 @@ class ValueSetChecker {
1284
1284
  let mid, m, p;
1285
1285
  if (mode === 'codeableConcept') {
1286
1286
  mid = 'TX_GENERAL_CC_ERROR_MESSAGE';
1287
- m = this.worker.i18n.translate('TX_GENERAL_CC_ERROR_MESSAGE', this.params.HTTPLanguages, [this.valueSet.vurl]);
1287
+ m = this.worker.i18n.translate('TX_GENERAL_CC_ERROR_MESSAGE', this.params.HTTPLanguages, [this.valueSet.vurlOrMsg]);
1288
1288
  } else {
1289
1289
  mid = 'None_of_the_provided_codes_are_in_the_value_set_one';
1290
- m = this.worker.i18n.translate('None_of_the_provided_codes_are_in_the_value_set_one', this.params.HTTPLanguages, ['', this.valueSet.vurl, codelist]);
1290
+ m = this.worker.i18n.translate('None_of_the_provided_codes_are_in_the_value_set_one', this.params.HTTPLanguages, ['', this.valueSet.vurlOrMsg, codelist]);
1291
1291
  }
1292
1292
 
1293
1293
  if (mode === 'codeableConcept') {