fhirsmith 0.16.1 → 0.18.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.
Files changed (142) hide show
  1. package/CHANGELOG.md +130 -0
  2. package/README.md +105 -30
  3. package/config-template.json +130 -4
  4. package/extension-tracker/extension-tracker.js +111 -3
  5. package/extension-tracker/readme.md +3 -0
  6. package/ips/ai-client.js +263 -0
  7. package/ips/composer.js +805 -0
  8. package/ips/editor-schema.js +289 -0
  9. package/ips/editor.js +290 -0
  10. package/ips/fhirpath-ips.js +397 -0
  11. package/ips/ips-codeable-concepts.md +1742 -0
  12. package/ips/ips-template.html +125 -0
  13. package/ips/ips-valuesets.js +159 -0
  14. package/ips/ips.js +1736 -0
  15. package/ips/membership.js +179 -0
  16. package/ips/pipeline.js +1012 -0
  17. package/ips/presenter.js +974 -0
  18. package/ips/profile-editor.js +578 -0
  19. package/ips/profiles.js +506 -0
  20. package/ips/readme.md +460 -0
  21. package/ips/render.js +1482 -0
  22. package/ips/search-params.js +83 -0
  23. package/ips/search.js +667 -0
  24. package/ips/share.js +178 -0
  25. package/ips/store.js +650 -0
  26. package/ips/terminology.js +395 -0
  27. package/ips/tx-client.js +202 -0
  28. package/ips/valuesets/CodeSystem-absent-unknown-uv-ips.json +206 -0
  29. package/ips/valuesets/ValueSet-absent-or-unknown-immunizations-uv-ips.json +59 -0
  30. package/ips/valuesets/ValueSet-ips-allergy-code.json +59 -0
  31. package/ips/valuesets/ValueSet-ips-allergy-manifestation.json +356 -0
  32. package/ips/valuesets/ValueSet-ips-allergy-substance.json +37 -0
  33. package/ips/valuesets/ValueSet-ips-body-site.json +26 -0
  34. package/ips/valuesets/ValueSet-ips-clinical-finding-or-none.json +35 -0
  35. package/ips/valuesets/ValueSet-ips-clinical-finding.json +26 -0
  36. package/ips/valuesets/ValueSet-ips-condition-code.json +72 -0
  37. package/ips/valuesets/ValueSet-ips-condition-severity.json +30 -0
  38. package/ips/valuesets/ValueSet-ips-condition-stage.json +26 -0
  39. package/ips/valuesets/ValueSet-ips-exposure-route.json +26 -0
  40. package/ips/valuesets/ValueSet-ips-imaging-procedure.json +25 -0
  41. package/ips/valuesets/ValueSet-ips-immunization-status-reason.json +43 -0
  42. package/ips/valuesets/ValueSet-ips-international-codes.json +25 -0
  43. package/ips/valuesets/ValueSet-ips-medication-ingredient.json +26 -0
  44. package/ips/valuesets/ValueSet-ips-medication-status-reason.json +37 -0
  45. package/ips/valuesets/ValueSet-ips-medication.json +50 -0
  46. package/ips/valuesets/ValueSet-ips-observation-code.json +18 -0
  47. package/ips/valuesets/ValueSet-ips-observation-interpretation.json +18 -0
  48. package/ips/valuesets/ValueSet-ips-procedure-followup.json +51 -0
  49. package/ips/valuesets/ValueSet-ips-procedure-outcome.json +30 -0
  50. package/ips/valuesets/ValueSet-ips-procedure-reason.json +37 -0
  51. package/ips/valuesets/ValueSet-ips-procedure.json +116 -0
  52. package/ips/valuesets/ValueSet-ips-route.json +46 -0
  53. package/ips/valuesets/ValueSet-ips-target-disease.json +171 -0
  54. package/ips/valuesets/ValueSet-ips-vaccine.json +31 -0
  55. package/ips/valuesets/elements.json +44 -0
  56. package/library/daily-report.js +422 -0
  57. package/library/fhir-search-values.js +138 -0
  58. package/library/html-server.js +32 -23
  59. package/library/html.js +9 -0
  60. package/library/mailer.js +77 -0
  61. package/library/narrative-check.js +222 -0
  62. package/library/npm-audit.js +25 -0
  63. package/library/package-manager.js +4 -2
  64. package/library/sqlite-session-store.js +29 -1
  65. package/package.json +8 -2
  66. package/packages/package-crawler.js +189 -82
  67. package/packages/packages.js +36 -63
  68. package/packages/utilities/dedupe-versions.js +22 -8
  69. package/publisher/publisher.js +29 -0
  70. package/registry/auth-store.js +366 -0
  71. package/registry/auth.js +872 -0
  72. package/registry/crawler.js +48 -1
  73. package/registry/documentation/api.md +162 -0
  74. package/registry/documentation/configuration.md +93 -0
  75. package/registry/documentation/documents.md +74 -0
  76. package/registry/documentation/internals.md +151 -0
  77. package/registry/documentation/readme.md +100 -0
  78. package/registry/documentation/security.md +199 -0
  79. package/registry/documents.js +309 -0
  80. package/registry/openapi.yaml +30 -0
  81. package/registry/registry-template.html +1 -1
  82. package/registry/registry.js +247 -54
  83. package/registry/uptime.js +154 -0
  84. package/release.sh +80 -0
  85. package/server.js +105 -44
  86. package/shl/shl.js +64 -9
  87. package/stats-db.js +93 -0
  88. package/stats.js +158 -1
  89. package/testing/readme.md +1 -1
  90. package/testing/search.js +2 -108
  91. package/testing/store.js +5 -0
  92. package/testing/testing.js +20 -0
  93. package/token/token-template.html +5 -4
  94. package/token/token.js +17 -10
  95. package/translations/Messages.properties +9 -0
  96. package/tx/README.md +52 -0
  97. package/tx/cm/cm-database.js +62 -0
  98. package/tx/cm/cm-package.js +20 -3
  99. package/tx/cs/cs-api.js +90 -14
  100. package/tx/cs/cs-cs.js +142 -19
  101. package/tx/cs/cs-icd11.js +366 -112
  102. package/tx/cs/cs-icf.js +418 -0
  103. package/tx/cs/cs-loinc.js +79 -39
  104. package/tx/cs/cs-omop.js +10 -3
  105. package/tx/cs/cs-snomed.js +918 -55
  106. package/tx/cs/cs-ucum.js +6 -0
  107. package/tx/cs/icd11-expressions.js +191 -78
  108. package/tx/importers/export-icd11-codesystem.js +58 -12
  109. package/tx/importers/icd11-schema.md +9 -5
  110. package/tx/library.js +107 -6
  111. package/tx/ocl/README.md +62 -6
  112. package/tx/ocl/cm-ocl.cjs +166 -44
  113. package/tx/ocl/cs-ocl.cjs +163 -13
  114. package/tx/ocl/resolve/public-access.js +143 -0
  115. package/tx/ocl/resolve/reference-resolver.js +458 -0
  116. package/tx/ocl/vs-ocl.cjs +167 -4
  117. package/tx/openapi-operations.js +4 -0
  118. package/tx/params.js +8 -1
  119. package/tx/problems.js +7 -5
  120. package/tx/provider.js +21 -5
  121. package/tx/sct/documentation.md +251 -0
  122. package/tx/sct/ecl.js +758 -46
  123. package/tx/sct/expressions.js +113 -52
  124. package/tx/syndication/syndication-feed.js +218 -0
  125. package/tx/syndication/syndication-store.js +415 -0
  126. package/tx/tx-html.js +37 -8
  127. package/tx/tx.fhir.org.yml +2 -3
  128. package/tx/tx.js +148 -5
  129. package/tx/vs/vs-database.js +26 -0
  130. package/tx/vs/vs-package.js +42 -10
  131. package/tx/vs/vs-vsac.js +25 -6
  132. package/tx/workers/expand.js +34 -7
  133. package/tx/workers/lookup.js +61 -6
  134. package/tx/workers/metadata.js +6 -1
  135. package/tx/workers/subsumes.js +1 -1
  136. package/tx/workers/translate.js +166 -15
  137. package/tx/workers/validate.js +128 -6
  138. package/utilities/make-sct-test-extension.js +264 -0
  139. package/utilities/submit-test-report.js +1 -1
  140. package/xig/xig.js +120 -12
  141. package/gr.sh +0 -3
  142. package/registry/readme.md +0 -124
package/tx/tx.js CHANGED
@@ -59,6 +59,8 @@ const ConceptUsageTracker = require("./usage-tracker");
59
59
  const ProblemFinder = require("./problems");
60
60
  const { buildOperationOutcome, outcomeFromError } = require('./library/operation-outcome');
61
61
  const txOpenApi = require('./openapi');
62
+ const { SyndicationStore } = require('./syndication/syndication-store');
63
+ const syndicationFeed = require('./syndication/syndication-feed');
62
64
  // const {writeFileSync} = require("fs");
63
65
 
64
66
  class TXModule {
@@ -140,9 +142,14 @@ class TXModule {
140
142
  * Initialize the TX module
141
143
  * @param {Object} config - Module configuration
142
144
  * @param {express.Application} app - Express application for registering endpoints
145
+ * @param {Object} [server] - server level settings: {hostName} (config.hostName)
143
146
  */
144
- async initialize(config, app) {
147
+ async initialize(config, app, server = {}) {
145
148
  this.config = config;
149
+ // the server's public host name, when it is configured as one (it is also used as a
150
+ // display name, so anything that isn't a host name is ignored)
151
+ this.publicHost = typeof server.hostName === 'string' && /^[A-Za-z0-9.-]+(:[0-9]{1,5})?$/.test(server.hostName)
152
+ ? server.hostName : null;
146
153
  // Initialize logger with config settings
147
154
  this.log = Logger.getInstance().child({
148
155
  module: 'tx',
@@ -166,6 +173,7 @@ class TXModule {
166
173
  // Off unless the operator opts in. This gates the nav item as well as the route, so a
167
174
  // server that does not publish its library source shows no link to it.
168
175
  txHtml.setPublishLibrarySource(config.publishLibrarySource === true);
176
+ txHtml.setSyndicationFeed(!!(config.syndication && config.syndication.enabled));
169
177
 
170
178
  // Validate config
171
179
  if (!config.librarySource) {
@@ -211,7 +219,7 @@ class TXModule {
211
219
  }
212
220
  };
213
221
  void prune(); // errors are logged inside
214
- this.timers.push(setInterval(prune, 24 * 60 * 60 * 1000));
222
+ this.timers.push(setInterval(() => void prune(), 24 * 60 * 60 * 1000));
215
223
  }
216
224
  }
217
225
 
@@ -236,6 +244,17 @@ class TXModule {
236
244
  // Load the library from YAML
237
245
  this.log.info(`Loading library from: ${config.librarySource}`);
238
246
  this.library = new Library(config.librarySource, config.vsacCfg, this.log, this.stats);
247
+
248
+ // The syndication feed: content is recorded in the store as the library loads it, from
249
+ // the time the administrator turns it on (see tx/syndication). One store serves every
250
+ // endpoint, since the library is shared. If it is turned on and the store can't be
251
+ // opened, that stops startup.
252
+ this.syndicationStore = null;
253
+ if (config.syndication && config.syndication.enabled) {
254
+ this.syndicationStore = new SyndicationStore(path.join(this.library.cacheFolder, 'syndication'), this.log).open();
255
+ this.library.syndication = this.syndicationStore;
256
+ this.log.info(`Syndication feed enabled: store in ${this.syndicationStore.folder}`);
257
+ }
239
258
  this.log.info(`Load...`);
240
259
  await this.library.load();
241
260
  this.log.info('Library loaded successfully');
@@ -305,13 +324,14 @@ class TXModule {
305
324
  if (this.stats) {
306
325
  this.stats.addTask("Client Cache", "5 min");
307
326
  }
308
- this.timers.push(setInterval(async () => {
327
+ const updateCodeSystemList = async () => {
309
328
  try {
310
329
  await endpointInfo.provider.updateCodeSystemList();
311
330
  } catch (error) {
312
331
  this.log.error(`Error updating CodeSystem list for ${endpointPath}: ${error.message}`);
313
332
  }
314
- }, 60 * 1000));
333
+ };
334
+ this.timers.push(setInterval(() => void updateCodeSystemList(), 60 * 1000)); // errors are logged inside
315
335
  this.log.info(`CodeSystem list update scheduled for ${endpointPath}`);
316
336
  this.timers.push(setInterval(() => {
317
337
  endpointInfo.resourceCache.prune(cacheTimeoutMs);
@@ -1132,6 +1152,10 @@ class TXModule {
1132
1152
  });
1133
1153
  }
1134
1154
 
1155
+ if (this.syndicationStore) {
1156
+ this.setupSyndicationRoutes(router, endpointPath);
1157
+ }
1158
+
1135
1159
  // Metadata / CapabilityStatement
1136
1160
  router.get('/metadata', async (req, res) => {
1137
1161
  const start = Date.now();
@@ -1331,6 +1355,31 @@ class TXModule {
1331
1355
  };
1332
1356
  }
1333
1357
 
1358
+ /**
1359
+ * The paragraph for the server's daily report: how many of each operation
1360
+ * were performed, over all the endpoints.
1361
+ */
1362
+ dailyReport({since, now}) {
1363
+ const {plural} = require('../library/daily-report');
1364
+ if (!this.stats) {
1365
+ return null;
1366
+ }
1367
+ const counts = this.stats.countsSince(since, now);
1368
+ if (counts.total === 0) {
1369
+ return 'No requests were served.';
1370
+ }
1371
+ // the FHIR operations first, busiest first, then the rest (pages, metadata, reads)
1372
+ const names = Object.keys(counts.operations).sort((a, b) => {
1373
+ const opA = a.startsWith('$') ? 0 : 1;
1374
+ const opB = b.startsWith('$') ? 0 : 1;
1375
+ return opA - opB || counts.operations[b] - counts.operations[a] || a.localeCompare(b);
1376
+ });
1377
+ const list = names.map(n => `${n} ${counts.operations[n].toLocaleString('en-US')}`).join(', ');
1378
+ const endpoints = Object.keys(counts.endpoints).filter(e => e).length;
1379
+ const where = endpoints > 1 ? ` over ${plural(endpoints, 'endpoint')}` : '';
1380
+ return `${counts.total.toLocaleString('en-US')} requests were served${where}: ${list}.`;
1381
+ }
1382
+
1334
1383
  /**
1335
1384
  * Shutdown the module
1336
1385
  */
@@ -1340,6 +1389,10 @@ class TXModule {
1340
1389
  clearInterval(timer);
1341
1390
  }
1342
1391
  this.timers = [];
1392
+ if (this.syndicationStore) {
1393
+ // let an induction in progress finish, so the store isn't left with a half-copied file
1394
+ await this.syndicationStore.whenIdle();
1395
+ }
1343
1396
  // Clean up any resources if needed
1344
1397
  await this.library.close();
1345
1398
  if (this.closureStore) {
@@ -1349,6 +1402,93 @@ class TXModule {
1349
1402
  this.log.info('TX module shut down');
1350
1403
  }
1351
1404
 
1405
+ /**
1406
+ * The syndication feed for an endpoint (Terminology Syndication Feed IG, RFC 5005
1407
+ * archived feeds) - see tx/syndication/syndication-feed.js. Atom unless HTML is asked for.
1408
+ */
1409
+ /**
1410
+ * The public url of an endpoint, for the links in its feed: from the configured host name
1411
+ * (config.hostName), so the links don't depend on what a client puts in the Host header.
1412
+ * Only without one does it fall back to the request (as for a local test server).
1413
+ */
1414
+ syndicationBase(req, endpointPath) {
1415
+ if (this.publicHost) {
1416
+ return `https://${this.publicHost}${endpointPath}`;
1417
+ }
1418
+ return `${req.protocol}://${req.get('host')}${endpointPath}`;
1419
+ }
1420
+
1421
+ setupSyndicationRoutes(router, endpointPath) {
1422
+ const sendFeed = async (req, res, page) => {
1423
+ const start = Date.now();
1424
+ try {
1425
+ const params = {
1426
+ store: this.syndicationStore,
1427
+ endpointPath,
1428
+ fhirVersion: req.txEndpoint.fhirVersion,
1429
+ base: this.syndicationBase(req, endpointPath),
1430
+ page,
1431
+ title: this.config.title,
1432
+ serverVersion: packageJson.version
1433
+ };
1434
+ if (txHtml.acceptsHtml(req)) {
1435
+ const content = syndicationFeed.renderHtml(params);
1436
+ if (content == null) {
1437
+ res.status(404).json(this.operationOutcome('error', 'not-found', 'There is no such syndication archive'));
1438
+ return;
1439
+ }
1440
+ let txhtml = new TxHtmlRenderer(new Renderer(req.txOpContext, req.txProvider), this.liquid, this.languages, this.i18n, endpointPath);
1441
+ const title = page == null ? 'Syndication Feed' : `Syndication Feed (archive ${page})`;
1442
+ const html = await txhtml.renderPage(title, content, req.txEndpoint, req.txStartTime);
1443
+ res.setHeader('Content-Type', 'text/html');
1444
+ res.send(html);
1445
+ return;
1446
+ }
1447
+ const xml = syndicationFeed.renderAtom(params);
1448
+ if (xml == null) {
1449
+ res.status(404).json(this.operationOutcome('error', 'not-found', 'There is no such syndication archive'));
1450
+ return;
1451
+ }
1452
+ // an archive never changes; the current document changes whenever content is added.
1453
+ // An archive is only cacheable forever when its links come from the configured host
1454
+ // name - links made from the request's Host header must not be cached for others
1455
+ res.setHeader('Cache-Control', page == null || !this.publicHost ? 'no-cache' : 'public, max-age=31536000, immutable');
1456
+ res.setHeader('Content-Type', 'application/atom+xml; charset=utf-8');
1457
+ res.setHeader('X-Content-Type-Options', 'nosniff');
1458
+ res.send(xml);
1459
+ } catch (error) {
1460
+ this.log.error(`Error rendering syndication feed: ${error.message}`);
1461
+ res.status(500).json(this.operationOutcome('error', 'exception', 'Unable to render the syndication feed'));
1462
+ } finally {
1463
+ this.countRequest(endpointPath, 'syndication', Date.now() - start);
1464
+ }
1465
+ };
1466
+
1467
+ router.get('/syndication', (req, res) => sendFeed(req, res, null));
1468
+
1469
+ router.get('/syndication/archive/:page', (req, res) => {
1470
+ const page = /^[1-9][0-9]{0,8}$/.test(req.params.page) ? Number(req.params.page) : -1;
1471
+ return sendFeed(req, res, page);
1472
+ });
1473
+
1474
+ // not rate limited here: as for the rest of the tx module, nginx does that (see nginx.md)
1475
+ router.get('/syndication/files/:name', (req, res) => {
1476
+ const file = this.syndicationStore.filePath(req.params.name);
1477
+ if (!file) {
1478
+ res.status(404).json(this.operationOutcome('error', 'not-found', 'There is no such syndication file'));
1479
+ return;
1480
+ }
1481
+ // copies in the store never change
1482
+ res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
1483
+ res.setHeader('Content-Type', 'application/fhir+json');
1484
+ // the file is whatever the source folder held: never let a browser sniff it as a page
1485
+ res.setHeader('X-Content-Type-Options', 'nosniff');
1486
+ // served from the store's folder by the store's own name for it (sendFile refuses
1487
+ // anything that would leave root)
1488
+ res.sendFile(path.basename(file), { root: this.syndicationStore.filesFolder });
1489
+ });
1490
+ }
1491
+
1352
1492
  trimParameters(params) {
1353
1493
  if (!params || !params.parameter) {
1354
1494
  return params;
@@ -1514,8 +1654,11 @@ class TXModule {
1514
1654
  if (hdr) {
1515
1655
  return parseInt(hdr);
1516
1656
  }
1657
+ // the tx-ecosystem test runner (Tools/Java) always gets the test limit, so every server
1658
+ // gives it the same answer. Otherwise the configured externalLimit - it used to be read
1659
+ // only when internalLimit was configured too, so setting it alone did nothing
1517
1660
  let isTest = req.header("User-Agent") == 'Tools/Java';
1518
- if (this.config.internalLimit && !isTest) return this.config.externalLimit; else return EXTERNAL_TEST_DEFAULT_LIMIT;
1661
+ if (this.config.externalLimit && !isTest) return this.config.externalLimit; else return EXTERNAL_TEST_DEFAULT_LIMIT;
1519
1662
  }
1520
1663
 
1521
1664
  }
@@ -4,6 +4,9 @@ const sqlite3 = require('sqlite3').verbose();
4
4
  const { VersionUtilities } = require('../../library/version-utilities');
5
5
  const ValueSet = require("../library/valueset");
6
6
 
7
+ // Settings key written once a package cache has been fully populated (see isComplete)
8
+ const CACHE_COMPLETE_KEY = 'cache-complete';
9
+
7
10
  // Columns that can be returned directly without parsing JSON
8
11
  const INDEXED_COLUMNS = ['id', 'url', 'version', 'date', 'description', 'name', 'publisher', 'status', 'title'];
9
12
 
@@ -485,6 +488,29 @@ class ValueSetDatabase {
485
488
  });
486
489
  }
487
490
 
491
+ /**
492
+ * True if this cache was fully populated: markComplete() was reached. A cache
493
+ * whose population was cut short (the process killed part way through) has no
494
+ * marker - and so does a file too damaged to open or read - so it reports false.
495
+ * @returns {Promise<boolean>}
496
+ */
497
+ async isComplete() {
498
+ try {
499
+ return (await this.getSetting(CACHE_COMPLETE_KEY)) != null;
500
+ } catch (e) {
501
+ return false;
502
+ }
503
+ }
504
+
505
+ /**
506
+ * Record that this cache has been fully populated. Called once, after the last
507
+ * value set has been written
508
+ * @returns {Promise<void>}
509
+ */
510
+ async markComplete() {
511
+ await this.setSetting(CACHE_COMPLETE_KEY, new Date().toISOString());
512
+ }
513
+
488
514
  /**
489
515
  * Insert or update a single ValueSet in the database
490
516
  * @param {Object} valueSet - The ValueSet resource
@@ -1,4 +1,5 @@
1
1
  const path = require('path');
2
+ const { assertNoActiveContent } = require('../../library/narrative-check');
2
3
  const fs = require('fs').promises;
3
4
  const { AbstractValueSetProvider } = require('./vs-api');
4
5
  const { PackageContentLoader } = require('../../library/package-manager');
@@ -21,7 +22,9 @@ class PackageValueSetProvider extends AbstractValueSetProvider {
21
22
  super();
22
23
  validateParameter(packageLoader, "packageLoader", PackageContentLoader);
23
24
  this.packageLoader = packageLoader;
24
- this.dbPath = path.join(packageLoader.packageFolder, '.valuesets.db');
25
+ // -2: caches from before value sets were checked for active content in their narrative
26
+ // are rebuilt (see initialize)
27
+ this.dbPath = path.join(packageLoader.packageFolder, '.valuesets-2.db');
25
28
  this.database = new ValueSetDatabase(this.dbPath);
26
29
  this.valueSetMap = new Map();
27
30
  this.initialized = false;
@@ -45,12 +48,21 @@ class PackageValueSetProvider extends AbstractValueSetProvider {
45
48
  return;
46
49
  }
47
50
  await this.packageLoader.initialize();
48
-
49
- const dbExists = await this.database.exists();
51
+ await fs.rm(path.join(this.packageLoader.packageFolder, '.valuesets.db'), { force: true }); // the old cache
52
+
53
+ // A cache is only used once its population finished. One that was cut short
54
+ // (the process killed part way through) would otherwise be trusted forever,
55
+ // silently missing whatever hadn't been written yet
56
+ let dbExists = await this.database.exists();
57
+ if (dbExists && !(await this.database.isComplete())) {
58
+ require('../../library/logger').getInstance().child({ module: 'tx' })
59
+ .warn(`Package ${this.packageLoader.pid ? this.packageLoader.pid() : ''}: value set cache ${this.dbPath} is incomplete - rebuilding it`);
60
+ await this.#discardDatabase();
61
+ dbExists = false;
62
+ }
50
63
 
51
64
  if (!dbExists) {
52
- await this.database.create();
53
- await this._populateDatabase();
65
+ await this.#buildDatabase();
54
66
  }
55
67
 
56
68
  this.valueSetMap = await this.database.loadAllValueSets(this.sourcePackage());
@@ -60,11 +72,8 @@ class PackageValueSetProvider extends AbstractValueSetProvider {
60
72
  // package-manager.js): if any cached value set still carries a "system|version"
61
73
  // include or a pipe in its url, rebuild this cache from the package.
62
74
  if (dbExists && this.#hasPipeVersionedCanonicals()) {
63
- await this.database.close();
64
- await fs.rm(this.dbPath, { force: true });
65
- this.database = new ValueSetDatabase(this.dbPath);
66
- await this.database.create();
67
- await this._populateDatabase();
75
+ await this.#discardDatabase();
76
+ await this.#buildDatabase();
68
77
  this.valueSetMap = await this.database.loadAllValueSets(this.sourcePackage());
69
78
  }
70
79
 
@@ -84,6 +93,27 @@ class PackageValueSetProvider extends AbstractValueSetProvider {
84
93
  this.initialized = true;
85
94
  }
86
95
 
96
+ /**
97
+ * Create the cache and fill it from the package. The complete marker is written
98
+ * last, so a build that doesn't finish is detected (and redone) next time
99
+ * @private
100
+ */
101
+ async #buildDatabase() {
102
+ await this.database.create();
103
+ await this._populateDatabase();
104
+ await this.database.markComplete();
105
+ }
106
+
107
+ /**
108
+ * Close and delete the cache file, leaving a fresh (not yet created) database
109
+ * @private
110
+ */
111
+ async #discardDatabase() {
112
+ await this.database.close();
113
+ await fs.rm(this.dbPath, { force: true });
114
+ this.database = new ValueSetDatabase(this.dbPath);
115
+ }
116
+
87
117
  /**
88
118
  * True if any loaded value set still has a pipe-versioned canonical (url or
89
119
  * compose include/exclude system) - the signature of a cache written before
@@ -134,6 +164,8 @@ class PackageValueSetProvider extends AbstractValueSetProvider {
134
164
  // don't store a value set the server can't use (e.g. one that contains a
135
165
  // CodeSystem): it would be listed by search, and fail when read
136
166
  checkContained(valueSet);
167
+ // its narrative is shown as-is in a page, so it must not carry script
168
+ assertNoActiveContent(valueSet);
137
169
  } catch (e) {
138
170
  require('../../library/logger').getInstance().child({ module: 'tx' })
139
171
  .warn(`Package ${this.packageLoader.pid ? this.packageLoader.pid() : ''}: skipping ValueSet ${valueSet.url}: ${e.message}`);
package/tx/vs/vs-vsac.js CHANGED
@@ -26,7 +26,7 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
26
26
  * @param {string} config.apiKey - API key for VSAC authentication
27
27
  * @param {string} config.cacheFolder - Local folder for cached database
28
28
  * @param {number} [config.refreshIntervalHours=24] - Hours between refresh scans
29
- * @param {string} [config.baseUrl='http://cts.nlm.nih.gov/fhir'] - Base URL for VSAC FHIR server
29
+ * @param {string} [config.baseUrl='https://cts.nlm.nih.gov/fhir'] - Base URL for VSAC FHIR server
30
30
  * @param {number} [config.timeoutMs=120000] - HTTP request timeout in milliseconds
31
31
  * @param {string} [config.resyncPassword] - If set, enables the operator "resync a ValueSet"
32
32
  * form on the /info page, gated by this password. If unset, the form is not offered.
@@ -43,7 +43,12 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
43
43
  // Optional operator password for the on-demand resync form (see info()).
44
44
  this.resyncPassword = config.resyncPassword || null;
45
45
  this.cacheFolder = folders.ensureFilePath("terminology-cache/vsac");
46
- this.baseUrl = config.baseUrl || 'http://cts.nlm.nih.gov/fhir';
46
+ // https: every request carries the API key. (VSAC redirects http to https, but only after
47
+ // the key has been sent in the clear)
48
+ this.baseUrl = (config.baseUrl || 'https://cts.nlm.nih.gov/fhir').replace(/\/+$/, '');
49
+ if (this.baseUrl.startsWith('http://')) {
50
+ console.warn(`VSAC: baseUrl ${this.baseUrl} is http, so the API key is sent unencrypted - use https`);
51
+ }
47
52
  this.refreshIntervalHours = config.refreshIntervalHours || 24;
48
53
 
49
54
  this.dbPath = path.join(this.cacheFolder, 'vsac-valuesets.db');
@@ -397,10 +402,24 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
397
402
  return null;
398
403
  }
399
404
 
400
- // Extract relative path from full URL
401
- let s = nextLink.url;
402
- s = s.replace(this.baseUrl, '');
403
- return s;
405
+ // Followed only on the VSAC server itself, as a path under baseUrl: the request carries the
406
+ // API key, so a next link must not be able to send it anywhere else. The scheme is ignored -
407
+ // VSAC's links can say http - and the request goes out on baseUrl's
408
+ let next;
409
+ let base;
410
+ try {
411
+ base = new URL(this.baseUrl);
412
+ next = new URL(nextLink.url, this.baseUrl + '/');
413
+ } catch (e) {
414
+ console.warn(`VSAC: ignoring next link ${String(nextLink.url).substring(0, 200)}: ${e.message}`);
415
+ return null;
416
+ }
417
+ const basePath = base.pathname.replace(/\/+$/, '');
418
+ if (next.host !== base.host || !(next.pathname === basePath || next.pathname.startsWith(basePath + '/'))) {
419
+ console.warn(`VSAC: ignoring next link ${String(nextLink.url).substring(0, 200)}: it is not on ${this.baseUrl}`);
420
+ return null;
421
+ }
422
+ return next.pathname.substring(basePath.length) + next.search;
404
423
  }
405
424
 
406
425
  /**
@@ -458,6 +458,19 @@ class ValueSetExpander {
458
458
  }
459
459
  }
460
460
 
461
+ // properties the code system always reports (ICD-11's classificationCode), unless
462
+ // the request asked for them anyway and the loops here add them
463
+ if (csProps != null && cs != null && !this.params.properties.includes('*')) {
464
+ for (const pn of cs.mandatoryExpansionProperties()) {
465
+ if (!this.params.properties.includes(pn)) {
466
+ for (const cp of csProps.filter(p => p.code === pn)) {
467
+ const vn = getValueName(cp);
468
+ this.defineProperty(expansion, n, this.getPropUrl(cs, pn, cp), pn, vn, cp[vn], true);
469
+ }
470
+ }
471
+ }
472
+ }
473
+
461
474
  for (const pn of this.params.properties) {
462
475
  if (pn === '*') {
463
476
  continue; // done above
@@ -836,7 +849,7 @@ class ValueSetExpander {
836
849
  notClosed.value = true;
837
850
  }
838
851
  const prep = await cs.getPrepContext(true);
839
- const ctxt = await cs.searchFilter(prep, filter, false);
852
+ const ctxt = await cs.searchFilter(prep, filter, true);
840
853
  let set = await cs.executeFilters(prep);
841
854
  if (this.count === 0 && valueSets.length === 0) {
842
855
  // count=0 (count only): report the filtered size directly without
@@ -890,8 +903,9 @@ class ValueSetExpander {
890
903
  const cctxt = await cs.locate(cc.code, this.allAltCodes);
891
904
  if (cctxt && cctxt.context && (!this.params.activeOnly || !await cs.isInactive(cctxt.context)) && await this.passesFilters(cs, cctxt.context, prep, filters, 0)) {
892
905
  let added;
906
+ const ecode = await cs.expansionCode(cctxt.context, cc.code);
893
907
  if (this.noDetails) {
894
- added = await this.includeCode(cs, null, cs.system(), cs.version(), cc.code, await cs.isAbstract(cctxt.context), await cs.isInactive(cctxt.context), null, null, cds,
908
+ added = await this.includeCode(cs, null, cs.system(), cs.version(), ecode, await cs.isAbstract(cctxt.context), await cs.isInactive(cctxt.context), null, null, cds,
895
909
  null, null, expansion, valueSets, null, null, null, null, excludeInactive, vsSrc.url);
896
910
  } else {
897
911
  await this.listDisplaysFromProvider(cds, cs, cctxt.context);
@@ -901,7 +915,7 @@ class ValueSetExpander {
901
915
  if (!ov) {
902
916
  ov = await cs.itemWeight(cctxt.context);
903
917
  }
904
- added = await this.includeCode(cs, null, cs.system(), cs.version(), cc.code, await cs.isAbstract(cctxt.context), await cs.isInactive(cctxt.context), await cs.isDeprecated(cctxt.context), await cs.getStatus(cctxt.context), cds,
918
+ added = await this.includeCode(cs, null, cs.system(), cs.version(), ecode, await cs.isAbstract(cctxt.context), await cs.isInactive(cctxt.context), await cs.isDeprecated(cctxt.context), await cs.getStatus(cctxt.context), cds,
905
919
  await cs.definition(cctxt.context), ov, expansion, valueSets, await cs.extensions(cctxt.context), cc.extension, await cs.properties(cctxt.context), null, excludeInactive, vsSrc.url);
906
920
  }
907
921
  if (added) {
@@ -939,16 +953,29 @@ class ValueSetExpander {
939
953
  }
940
954
 
941
955
  const fset = await cs.executeFilters(prep);
942
- if (await cs.filtersNotClosed(prep)) {
956
+ const filtersOpen = await cs.filtersNotClosed(prep);
957
+ if (filtersOpen) {
943
958
  notClosed.value = true;
944
- } else if (fset.length === 1 && !excludeInactive && !this.params.activeOnly) {
945
- // this.addToTotal(await cs.filterSize(prep, fset[0]));
959
+ }
960
+
961
+ // count=0 asks only for the total. When this include is the whole value set and its one
962
+ // filter set is the whole answer - nothing else to add, overlap, exclude or skip - the
963
+ // total is the set's size, and walking it would only end in VALUESET_TOO_COSTLY once
964
+ // the set is bigger than the limit. Anything less certain is counted by walking, as
965
+ // before: an earlier version added filterSize *and* walked, and double counted (#91, #93).
966
+ const countOnly = this.count === 0 && !filtersOpen && fset.length === 1 && filter.isNull
967
+ && !excludeInactive && !this.params.activeOnly && !this.hasExclusions
968
+ && !(cset.concept && cset.concept.length) && !(cset.valueSet && cset.valueSet.length)
969
+ && (compose.include || []).length === 1 && (compose.exclude || []).length === 0;
970
+ if (countOnly) {
971
+ this.worker.opContext.log('count only: the filter size is the total');
972
+ this.addToTotal(await cs.filterSize(prep, fset[0]));
946
973
  }
947
974
 
948
975
  this.worker.opContext.log('iterate filters');
949
976
  this.addToTotal(0);
950
977
  const cds = new Designations(this.worker.i18n.languageDefinitions);
951
- while (await cs.filterMore(prep, fset[0])) {
978
+ while (!countOnly && await cs.filterMore(prep, fset[0])) {
952
979
  await this.worker.checkAndYield('processCodes#5');
953
980
  const c = await cs.filterConcept(prep, fset[0]);
954
981
  const ok = (!this.params.activeOnly || !await cs.isInactive(c)) && (await this.passesFilters(cs, c, prep, fset, 1));
@@ -15,6 +15,7 @@ const {Parameters} = require("../library/parameters");
15
15
  const { Issue, OperationOutcome, buildOperationOutcome, outcomeFromError } = require('../library/operation-outcome');
16
16
  const {debugLog} = require("../operation-context");
17
17
  const {checkAddedParameters} = require("../library/parameter-check");
18
+ const { Languages } = require("../../library/languages");
18
19
 
19
20
  class LookupWorker extends TerminologyWorker {
20
21
  /**
@@ -204,7 +205,7 @@ class LookupWorker extends TerminologyWorker {
204
205
  const supplements = this.loadSupplements(codeSystem.url, codeSystem.version, txp.supplements);
205
206
 
206
207
  // Create a FhirCodeSystemProvider for this CodeSystem
207
- const csProvider = new FhirCodeSystemProvider(this.opContext, codeSystem, supplements);
208
+ const csProvider = FhirCodeSystemProvider.create(this.opContext, codeSystem, supplements);
208
209
 
209
210
  // Perform the lookup
210
211
  const result = await this.doLookup(csProvider, code, txp);
@@ -247,6 +248,11 @@ class LookupWorker extends TerminologyWorker {
247
248
  );
248
249
  };
249
250
 
251
+ // Explicitly asked for: named in the property list, or '*'. An empty list is not asking for
252
+ // anything in particular (the server chooses), which is not the same thing for abstract and
253
+ // notSelectable - see below.
254
+ const asked = (name) => (params.properties || []).some(p => p === '*' || p.toLowerCase() === name.toLowerCase());
255
+
250
256
  // Locate the code in the code system
251
257
  const locateResult = await csProvider.locate(code);
252
258
 
@@ -271,7 +277,7 @@ class LookupWorker extends TerminologyWorker {
271
277
  // name (required)
272
278
  responseParams.push({
273
279
  name: 'name',
274
- valueString: csProvider.name(params.workingLanguages())
280
+ valueString: csProvider.lookupName(params.workingLanguages())
275
281
  });
276
282
  responseParams.push({
277
283
  name: 'code',
@@ -303,6 +309,32 @@ class LookupWorker extends TerminologyWorker {
303
309
  valueString: disp || display || code
304
310
  });
305
311
 
312
+ // A displayLanguage the code system has no display in, for this concept, still gets an
313
+ // answer - in the default language - but the client is told so: a display in a language it
314
+ // did not ask for, offered without comment, reads as an answer in the language it did.
315
+ // Only for an explicit displayLanguage: Accept-Language is a browser's standing preference,
316
+ // not a request about this code.
317
+ if (params.hasDisplayLanguages) {
318
+ // the languages actually asked for: displayLanguage is parsed with a wildcard appended,
319
+ // and the wildcard matches whatever display there is
320
+ const langs = new Languages(this.languages);
321
+ for (const l of params.workingLanguages()) {
322
+ if (l.code !== '*') langs.add(l);
323
+ }
324
+ const covered = langs.length === 0 || (designations.designations.length > 0
325
+ ? designations.hasAnyDisplays(langs)
326
+ : csProvider.hasAnyDisplays(langs));
327
+ if (!covered) {
328
+ const used = (pd && pd.language) ? String(pd.language) : (csProvider.defLang() || 'en');
329
+ const csDesc = version ? csProvider.system() + '|' + version : csProvider.system();
330
+ responseParams.push({
331
+ name: 'warning',
332
+ valueString: this.i18n.translate('LOOKUP_DISPLAY_LANGUAGE_FALLBACK', params.HTTPLanguages,
333
+ [langs.asString(false), code, csDesc, used])
334
+ });
335
+ }
336
+ }
337
+
306
338
  // definition (optional) - top-level parameter
307
339
  if (hasProp('definition', true)) {
308
340
  const definition = await csProvider.definition(ctxt);
@@ -314,9 +346,12 @@ class LookupWorker extends TerminologyWorker {
314
346
  }
315
347
  }
316
348
 
317
- // abstract property (optional)
318
- if (hasProp('abstract', true)) {
319
- const isAbstract = await csProvider.isAbstract(ctxt);
349
+ // abstract and notSelectable (agreed for the tx-ecosystem tests, October 2026): a concept that
350
+ // is not selectable always says so, both ways, whatever properties were asked for - that is
351
+ // the fact a client most needs about it. A selectable one says neither unless asked.
352
+ // notSelectable is reconciled after the provider has added its properties, below.
353
+ const isAbstract = await csProvider.isAbstract(ctxt);
354
+ if (isAbstract || asked('abstract')) {
320
355
  responseParams.push({
321
356
  name: 'abstract',
322
357
  valueBoolean: isAbstract
@@ -389,7 +424,7 @@ class LookupWorker extends TerminologyWorker {
389
424
  // primitive belongs is a provider bug, and shipping it would produce a
390
425
  // response that isn't valid FHIR (and, in XML, isn't even parseable).
391
426
  const providerParamsFrom = responseParams.length;
392
- await csProvider.extendLookup(ctxt, params.properties || [], responseParams);
427
+ await csProvider.extendLookup(ctxt, params.properties || [], responseParams, params);
393
428
  let providerName;
394
429
  try {
395
430
  providerName = csProvider.system();
@@ -398,6 +433,26 @@ class LookupWorker extends TerminologyWorker {
398
433
  }
399
434
  checkAddedParameters(responseParams, providerParamsFrom, providerName);
400
435
 
436
+ // notSelectable, the property: present (true) for every concept that is not selectable;
437
+ // for a selectable one only when asked for (false), and taken out if a provider added it
438
+ // anyway
439
+ const isNotSelectable = (p) => p.name === 'property' &&
440
+ (p.part || []).some(x => x.name === 'code' && x.valueCode === 'notSelectable');
441
+ const nsAt = responseParams.findIndex(isNotSelectable);
442
+ if (isAbstract) {
443
+ if (nsAt < 0) {
444
+ responseParams.push({ name: 'property', part: [
445
+ { name: 'code', valueCode: 'notSelectable' }, { name: 'value', valueBoolean: true }] });
446
+ }
447
+ } else if (asked('notSelectable')) {
448
+ if (nsAt < 0) {
449
+ responseParams.push({ name: 'property', part: [
450
+ { name: 'code', valueCode: 'notSelectable' }, { name: 'value', valueBoolean: false }] });
451
+ }
452
+ } else if (nsAt >= 0) {
453
+ responseParams.splice(nsAt, 1);
454
+ }
455
+
401
456
  if (reportedSupplements) {
402
457
  for (const supplement of reportedSupplements) {
403
458
  responseParams.push({
@@ -472,7 +472,12 @@ class MetadataHandler {
472
472
  { name: 'useSupplement' },
473
473
  { name: 'valueSet' },
474
474
  { name: 'valueSetVersion' }
475
- ]
475
+ ],
476
+ textFilter: 'On code systems loaded from files and packages, a text filter ignores case, and accents on Latin, Greek and Cyrillic letters. ' +
477
+ 'It matches concepts whose code, display, definition or a designation contains the text. ' +
478
+ 'A filter of several words also matches a display or designation in which each word starts a word, ' +
479
+ 'in any order; for this, punctuation and spaces only separate words. The closest matches come first. ' +
480
+ 'Other code systems, such as SNOMED CT, LOINC and RxNorm, match text in their own way.'
476
481
  };
477
482
  }
478
483
 
@@ -177,7 +177,7 @@ class SubsumesWorker extends TerminologyWorker {
177
177
  const supplements = this.loadSupplements(codeSystem.url, codeSystem.version, txp.supplements);
178
178
 
179
179
  // Create a FhirCodeSystemProvider for this CodeSystem
180
- const csProvider = new FhirCodeSystemProvider(this.opContext, codeSystem, supplements);
180
+ const csProvider = FhirCodeSystemProvider.create(this.opContext, codeSystem, supplements);
181
181
 
182
182
  // Get the codings
183
183
  let codingA, codingB;