fhirsmith 0.11.2 → 0.13.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 (68) hide show
  1. package/CHANGELOG.md +377 -2
  2. package/README.md +1 -1
  3. package/config-template.json +31 -1
  4. package/library/languages.js +191 -34
  5. package/library/path-safety.js +51 -0
  6. package/library/request-token.js +56 -0
  7. package/package.json +2 -2
  8. package/packages/packages.js +17 -118
  9. package/publisher/publisher.js +208 -30
  10. package/publisher/validation.js +210 -0
  11. package/server.js +24 -15
  12. package/stats-db.js +127 -0
  13. package/stats.js +172 -6
  14. package/translations/Messages.properties +5 -1
  15. package/tx/README.md +21 -0
  16. package/tx/cs/cs-api.js +30 -1
  17. package/tx/cs/cs-api.md +1 -1
  18. package/tx/cs/cs-cs.js +1 -1
  19. package/tx/cs/cs-icd11.js +805 -0
  20. package/tx/cs/cs-lang.js +331 -85
  21. package/tx/cs/cs-loinc.js +45 -4
  22. package/tx/cs/cs-mimetypes.js +492 -21
  23. package/tx/cs/cs-snomed.js +141 -58
  24. package/tx/cs/cs-ucum.js +33 -3
  25. package/tx/cs/icd11-expressions.js +243 -0
  26. package/tx/importers/export-icd11-codesystem.js +823 -0
  27. package/tx/importers/icd11-schema.md +285 -0
  28. package/tx/importers/import-icd11.js +861 -0
  29. package/tx/importers/import-loinc.module.js +14 -7
  30. package/tx/importers/import-sct.module.js +10 -1
  31. package/tx/library/codesystem.js +21 -27
  32. package/tx/library/conceptmap.js +39 -0
  33. package/tx/library/nesting-limit.js +79 -0
  34. package/tx/library/operation-outcome.js +97 -3
  35. package/tx/library/parameter-check.js +139 -0
  36. package/tx/library/valueset.js +16 -20
  37. package/tx/library.js +23 -1
  38. package/tx/ocl/cache/cache-utils.cjs +5 -3
  39. package/tx/ocl/cs-ocl.cjs +73 -17
  40. package/tx/ocl/jobs/background-queue.cjs +6 -3
  41. package/tx/ocl/mappers/concept-mapper.cjs +9 -1
  42. package/tx/ocl/shared/patches.cjs +48 -1
  43. package/tx/ocl/vs-ocl.cjs +15 -13
  44. package/tx/operation-context.js +220 -10
  45. package/tx/params.js +226 -175
  46. package/tx/sct/expressions.js +677 -15
  47. package/tx/tx.fhir.org.yml +3 -2
  48. package/tx/tx.js +72 -92
  49. package/tx/workers/batch-validate.js +11 -18
  50. package/tx/workers/batch.js +16 -28
  51. package/tx/workers/cache-control.js +87 -17
  52. package/tx/workers/closure.js +3 -8
  53. package/tx/workers/compare.js +6 -34
  54. package/tx/workers/expand.js +108 -60
  55. package/tx/workers/lookup.js +35 -40
  56. package/tx/workers/read.js +6 -40
  57. package/tx/workers/search.js +2 -8
  58. package/tx/workers/subsumes.js +58 -41
  59. package/tx/workers/translate.js +472 -47
  60. package/tx/workers/validate.js +83 -78
  61. package/tx/workers/worker.js +92 -11
  62. package/tx/xml/xml-base.js +264 -77
  63. package/tx/xversion/xv-conceptmap.js +81 -0
  64. package/tx/xversion/xv-parameters.js +8 -0
  65. package/tx/xversion/xv-valueset.js +39 -1
  66. package/utilities/merge-audit.py +178 -0
  67. package/utilities/why-merge-audit.md +39 -0
  68. package/tx/data/snomed-subset.txt +0 -84
package/server.js CHANGED
@@ -15,6 +15,7 @@ const v8 = require('v8');
15
15
  const folders = require('./library/folder-setup'); // <-- ADD: load early
16
16
  const { statSync, readdirSync } = require('fs');
17
17
  const escape = require('escape-html');
18
+ const { resolveWithin } = require('./library/path-safety');
18
19
 
19
20
  // Load configuration BEFORE logger
20
21
  let config;
@@ -99,13 +100,13 @@ let stats = null;
99
100
 
100
101
  // Initialize modules based on configuration
101
102
  async function initializeModules() {
102
- stats = new ServerStats();
103
+ stats = new ServerStats(config.stats, serverLog);
103
104
 
104
105
  // Initialize SHL module
105
106
  if (config.modules?.shl?.enabled) {
106
107
  try {
107
108
  serverLog.info('Initializing module: shl...');
108
- modules.shl = new SHLModule(stats);
109
+ modules.shl = new SHLModule(stats.forModule('shl'));
109
110
  await modules.shl.initialize(config.modules.shl);
110
111
  app.use('/shl', modules.shl.router);
111
112
  } catch (error) {
@@ -118,7 +119,7 @@ async function initializeModules() {
118
119
  if (config.modules?.vcl?.enabled) {
119
120
  try {
120
121
  serverLog.info('Initializing module: vcl...');
121
- modules.vcl = new VCLModule(stats);
122
+ modules.vcl = new VCLModule(stats.forModule('vcl'));
122
123
  await modules.vcl.initialize(config.modules.vcl);
123
124
  app.use('/VCL', modules.vcl.router);
124
125
  } catch (error) {
@@ -131,7 +132,7 @@ async function initializeModules() {
131
132
  if (config.modules?.xig?.enabled) {
132
133
  try {
133
134
  serverLog.info('Initializing module: xig...');
134
- await xigModule.initializeXigModule(stats, config.modules.xig);
135
+ await xigModule.initializeXigModule(stats.forModule('xig'), config.modules.xig);
135
136
  app.use('/xig', xigModule.router);
136
137
  modules.xig = xigModule;
137
138
  } catch (error) {
@@ -144,7 +145,7 @@ async function initializeModules() {
144
145
  if (config.modules?.packages?.enabled) {
145
146
  try {
146
147
  serverLog.info('Initializing module: packages...');
147
- modules.packages = new PackagesModule(stats);
148
+ modules.packages = new PackagesModule(stats.forModule('packages'));
148
149
  await modules.packages.initialize(config.modules.packages);
149
150
  app.use('/packages', modules.packages.router);
150
151
  } catch (error) {
@@ -158,7 +159,7 @@ async function initializeModules() {
158
159
  if (config.modules?.registry?.enabled) {
159
160
  try {
160
161
  serverLog.info('Initializing module: registry...');
161
- modules.registry = new RegistryModule(stats);
162
+ modules.registry = new RegistryModule(stats.forModule('registry'));
162
163
  await modules.registry.initialize(config.modules.registry);
163
164
  app.use('/tx-reg', modules.registry.router);
164
165
  } catch (error) {
@@ -171,7 +172,7 @@ async function initializeModules() {
171
172
  if (config.modules?.publisher?.enabled) {
172
173
  try {
173
174
  serverLog.info('Initializing module: publisher...');
174
- modules.publisher = new PublisherModule(stats);
175
+ modules.publisher = new PublisherModule(stats.forModule('publisher'));
175
176
  await modules.publisher.initialize(config.modules.publisher);
176
177
  app.use('/publisher', modules.publisher.router);
177
178
  } catch (error) {
@@ -184,7 +185,7 @@ async function initializeModules() {
184
185
  if (config.modules?.token?.enabled) {
185
186
  try {
186
187
  serverLog.info('Initializing module: token...');
187
- modules.token = new TokenModule(stats);
188
+ modules.token = new TokenModule(stats.forModule('token'));
188
189
  await modules.token.initialize(config.modules.token);
189
190
  app.use('/token', modules.token.router);
190
191
  } catch (error) {
@@ -197,7 +198,7 @@ async function initializeModules() {
197
198
  if (config.modules?.npmprojector?.enabled) {
198
199
  try {
199
200
  serverLog.info('Initializing module: npmprojector...');
200
- modules.npmprojector = new NpmProjectorModule(stats);
201
+ modules.npmprojector = new NpmProjectorModule(stats.forModule('npmprojector'));
201
202
  await modules.npmprojector.initialize(config.modules.npmprojector);
202
203
  const basePath = NpmProjectorModule.getBasePath(config.modules.npmprojector);
203
204
  app.use(basePath, modules.npmprojector.router);
@@ -211,7 +212,7 @@ async function initializeModules() {
211
212
  if (config.modules?.['ext-tracker']?.enabled) {
212
213
  try {
213
214
  serverLog.info('Initializing module: ext-tracker...');
214
- modules.extTracker = new ExtensionTrackerModule(stats);
215
+ modules.extTracker = new ExtensionTrackerModule(stats.forModule('ext-tracker'));
215
216
  await modules.extTracker.initialize(config.modules['ext-tracker'], app);
216
217
  } catch (error) {
217
218
  serverLog.error('Failed to initialize extension tracker module:', error);
@@ -224,7 +225,7 @@ async function initializeModules() {
224
225
  if (config.modules?.tx?.enabled) {
225
226
  try {
226
227
  serverLog.info('Initializing module: tx...');
227
- modules.tx = new TXModule(stats);
228
+ modules.tx = new TXModule(stats.forModule('tx'));
228
229
  await modules.tx.initialize(config.modules.tx, app);
229
230
  } catch (error) {
230
231
  serverLog.error('Failed to initialize TX module:', error);
@@ -235,7 +236,7 @@ async function initializeModules() {
235
236
  if (config.modules?.folder?.enabled) {
236
237
  try {
237
238
  serverLog.info('Initializing module: folder...');
238
- modules.folder = new FolderModule(stats);
239
+ modules.folder = new FolderModule(stats.forModule('folder'));
239
240
  await modules.folder.initialize(config.modules.folder, app);
240
241
  // mount the router
241
242
  } catch (error) {
@@ -540,10 +541,18 @@ app.use((req, res, next) => {
540
541
  });
541
542
  if (config.server?.webBase) {
542
543
  const overrideDir = path.resolve(config.server.webBase);
544
+ // Anything under overrideDir may be served; nothing outside it may - see
545
+ // library/path-safety.js for why path.join is not enough on its own.
543
546
  app.use((req, res, next) => {
544
- const filePath = path.join(overrideDir, req.path);
545
- fs.access(filePath, fs.constants.F_OK, (err) => {
546
- if (!err) {
547
+ const filePath = resolveWithin(overrideDir, req.path);
548
+ if (filePath === null) {
549
+ return next();
550
+ }
551
+ // stat rather than access: a directory passes an existence check and then makes
552
+ // sendFile fail with EISDIR, where falling through to express.static serves its
553
+ // index.html instead.
554
+ fs.stat(filePath, (err, stat) => {
555
+ if (!err && stat.isFile()) {
547
556
  res.sendFile(filePath);
548
557
  } else {
549
558
  next();
package/stats-db.js ADDED
@@ -0,0 +1,127 @@
1
+ const path = require('path');
2
+ const fs = require('fs');
3
+ const Database = require('better-sqlite3');
4
+ const folders = require('./library/folder-setup');
5
+
6
+ /**
7
+ * Persistent storage for the server's request statistics (issue #255).
8
+ *
9
+ * The in-memory counters in ServerStats reset every time the server restarts,
10
+ * which makes them useless for anything but "what has happened since the last
11
+ * deploy". This class writes them to a SQLite database that outlives restarts
12
+ * and upgrades.
13
+ *
14
+ * One row per (module, endpoint, operation) per collection interval, holding
15
+ * both numbers:
16
+ *
17
+ * - the *delta*: what happened in that interval. This is what you graph -
18
+ * plot count_delta against time and you have requests per interval without
19
+ * any differencing at the query end.
20
+ * - the *total*: the all-time cumulative count, carried across restarts by
21
+ * reloading it at startup. This is what you quote as "how much has this
22
+ * endpoint ever been asked to do", and it means a restart shows up as a
23
+ * flat spot in the graph rather than a cliff back to zero.
24
+ *
25
+ * Rows are only written for keys that saw traffic in the interval, so idle
26
+ * endpoints cost nothing. Nothing is pruned: at 10 minute intervals a
27
+ * continuously busy (endpoint, operation) pair produces ~52,000 rows a year,
28
+ * which SQLite does not notice.
29
+ *
30
+ * better-sqlite3 (rather than sqlite3) on purpose: the writes are synchronous,
31
+ * so the final flush in ServerStats.finishStats() lands on disk before
32
+ * server.js calls process.exit(). A handful of inserts every ten minutes is
33
+ * well under a millisecond, so this does not put the event loop at risk.
34
+ */
35
+ class StatsDatabase {
36
+
37
+ constructor(config, log) {
38
+ this.log = log || console;
39
+ this.config = config || {};
40
+ this.db = null;
41
+ this.path = StatsDatabase.resolvePath(this.config);
42
+ }
43
+
44
+ /**
45
+ * Where the database lives: config.database if given (relative paths resolve
46
+ * under the data folder's databases directory), otherwise stats.db there.
47
+ */
48
+ static resolvePath(config) {
49
+ const configured = config && config.database;
50
+ if (!configured) {
51
+ return path.join(folders.databasesDir(), 'stats.db');
52
+ }
53
+ return path.isAbsolute(configured) ? configured : path.join(folders.databasesDir(), configured);
54
+ }
55
+
56
+ /**
57
+ * Open the database, creating the file and the schema if they aren't there.
58
+ */
59
+ open() {
60
+ fs.mkdirSync(path.dirname(this.path), {recursive: true});
61
+ this.db = new Database(this.path);
62
+ this.db.pragma('journal_mode = WAL');
63
+ this.db.exec(`
64
+ CREATE TABLE IF NOT EXISTS request_counts (
65
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
66
+ time INTEGER NOT NULL,
67
+ interval_ms INTEGER NOT NULL,
68
+ module TEXT NOT NULL,
69
+ endpoint TEXT NOT NULL,
70
+ operation TEXT NOT NULL,
71
+ count_delta INTEGER NOT NULL,
72
+ count_total INTEGER NOT NULL,
73
+ time_delta INTEGER NOT NULL,
74
+ time_total INTEGER NOT NULL
75
+ )
76
+ `);
77
+ this.db.exec(`CREATE INDEX IF NOT EXISTS idx_request_counts_time ON request_counts(time)`);
78
+ this.db.exec(`CREATE INDEX IF NOT EXISTS idx_request_counts_key ON request_counts(module, endpoint, operation, id)`);
79
+
80
+ this.insertStatement = this.db.prepare(`
81
+ INSERT INTO request_counts
82
+ (time, interval_ms, module, endpoint, operation, count_delta, count_total, time_delta, time_total)
83
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
84
+ `);
85
+ this.insertAll = this.db.transaction((time, intervalMs, rows) => {
86
+ for (const row of rows) {
87
+ this.insertStatement.run(time, intervalMs, row.module, row.endpoint, row.operation,
88
+ row.countDelta, row.countTotal, row.timeDelta, row.timeTotal);
89
+ }
90
+ });
91
+ return this;
92
+ }
93
+
94
+ /**
95
+ * The most recent cumulative total for each key, so counting resumes where
96
+ * the last run left off instead of starting again from zero.
97
+ */
98
+ loadTotals() {
99
+ if (!this.db) {
100
+ return [];
101
+ }
102
+ return this.db.prepare(`
103
+ SELECT module, endpoint, operation, count_total AS countTotal, time_total AS timeTotal
104
+ FROM request_counts
105
+ WHERE id IN (SELECT MAX(id) FROM request_counts GROUP BY module, endpoint, operation)
106
+ `).all();
107
+ }
108
+
109
+ /**
110
+ * Write one interval's worth of rows. All or nothing - if the transaction
111
+ * fails the caller keeps its deltas and tries again next interval.
112
+ */
113
+ write(time, intervalMs, rows) {
114
+ if (this.db && rows && rows.length > 0) {
115
+ this.insertAll(time, intervalMs, rows);
116
+ }
117
+ }
118
+
119
+ close() {
120
+ if (this.db) {
121
+ this.db.close();
122
+ this.db = null;
123
+ }
124
+ }
125
+ }
126
+
127
+ module.exports = StatsDatabase;
package/stats.js CHANGED
@@ -1,6 +1,49 @@
1
1
  const { monitorEventLoopDelay } = require('perf_hooks');
2
2
  const {Utilities} = require("./library/utilities");
3
3
  const escape = require('escape-html');
4
+ const StatsDatabase = require('./stats-db');
5
+
6
+ // Requests that aren't counted by a module (the root page, the dashboard) are
7
+ // attributed here, and requests that have no endpoint dimension (everything
8
+ // outside the tx module) carry NO_ENDPOINT.
9
+ const SERVER_MODULE = '(server)';
10
+ const NO_ENDPOINT = '';
11
+
12
+ /**
13
+ * A per-module view of the server statistics.
14
+ *
15
+ * Modules are handed one of these instead of the ServerStats itself, so that
16
+ * everything they count is attributed to them without each of the ~76 call
17
+ * sites having to name its own module. The API is deliberately the same shape
18
+ * as ServerStats, so a module can't tell the difference - except that
19
+ * countRequest takes an optional endpoint, which is how the tx module
20
+ * distinguishes /tx/r4 from /tx/r5.
21
+ */
22
+ class ModuleStats {
23
+
24
+ constructor(parent, module) {
25
+ this.parent = parent;
26
+ this.module = module;
27
+ }
28
+
29
+ countRequest(name, tat, endpoint = NO_ENDPOINT) {
30
+ this.parent.countRequest(name, tat, this.module, endpoint);
31
+ }
32
+
33
+ addTask(name, frequency) { this.parent.addTask(name, frequency); }
34
+ task(name, state) { this.parent.task(name, state); }
35
+ taskDone(name, state) { this.parent.taskDone(name, state); }
36
+ taskError(name, state) { this.parent.taskError(name, state); }
37
+
38
+ // Pass-throughs for the handful of properties modules read off the stats
39
+ // object. These have to be getters, not copies, or a module would capture
40
+ // the value at construction time and never see it change.
41
+ get cachingModules() { return this.parent.cachingModules; }
42
+ get requestCount() { return this.parent.requestCount; }
43
+ get staticRequestCount() { return this.parent.staticRequestCount; }
44
+ get history() { return this.parent.history; }
45
+ get startTime() { return this.parent.startTime; }
46
+ }
4
47
 
5
48
  class ServerStats {
6
49
  started = false;
@@ -16,13 +59,53 @@ class ServerStats {
16
59
  timer;
17
60
  cachingModules = [];
18
61
  taskMap = new Map();
62
+ // key (module/endpoint/operation) -> counter. See countRequest().
63
+ counters = new Map();
64
+ db = null;
19
65
 
20
- constructor() {
66
+ constructor(config, log) {
67
+ this.config = config || {};
68
+ this.log = log || console;
69
+ if (this.config.intervalMinutes) {
70
+ this.intervalMs = this.config.intervalMinutes * 60 * 1000;
71
+ }
72
+ this.openDatabase();
21
73
  this.timer = setInterval(() => {
22
74
  this.recordMetrics();
23
75
  }, this.intervalMs);
24
76
  }
25
77
 
78
+ /**
79
+ * Open the persistent counter store and pick up where the last run of the
80
+ * server left off. Statistics must never be the reason the server won't
81
+ * start, so a failure here is logged and then ignored: the counters carry
82
+ * on in memory, they just don't survive the next restart.
83
+ */
84
+ openDatabase() {
85
+ if (this.config.enabled === false) {
86
+ return;
87
+ }
88
+ try {
89
+ this.db = new StatsDatabase(this.config, this.log).open();
90
+ for (const row of this.db.loadTotals()) {
91
+ const counter = this.counterFor(row.module, row.endpoint, row.operation);
92
+ counter.countTotal = row.countTotal;
93
+ counter.timeTotal = row.timeTotal;
94
+ }
95
+ } catch (e) {
96
+ this.log.error(`Unable to open the statistics database: ${e.message}. Statistics will not be persisted`);
97
+ this.db = null;
98
+ }
99
+ }
100
+
101
+ /**
102
+ * A view of the statistics that attributes everything counted through it to
103
+ * the named module (see ModuleStats).
104
+ */
105
+ forModule(module) {
106
+ return new ModuleStats(this, module);
107
+ }
108
+
26
109
  recordMetrics() {
27
110
  if (this.started) {
28
111
  const now = Date.now();
@@ -61,9 +144,12 @@ class ServerStats {
61
144
  this.requestTime = 0;
62
145
  this.lastTime = now;
63
146
 
64
- // Prune old data (keep 24 hours)
147
+ // Prune old data (keep 24 hours). Only the in-memory history is pruned -
148
+ // the per-endpoint counters are persisted and kept indefinitely.
65
149
  const cutoff = now - (24 * 60 * 60 * 1000); // 24 hours ago
66
150
  this.history = this.history.filter(m => m.time > cutoff);
151
+
152
+ this.flushCounters(now, this.intervalMs);
67
153
  }
68
154
  }
69
155
 
@@ -77,11 +163,78 @@ class ServerStats {
77
163
  this.recordMetrics();
78
164
  }
79
165
 
80
- countRequest(name, tat) {
81
- // we ignore name for now, but we might split the tat tracking up by name
82
- // at some stage
166
+ /**
167
+ * The counter for one (module, endpoint, operation), created on first use.
168
+ *
169
+ * countDelta/timeDelta accumulate within the current interval and are reset
170
+ * each time they're written out; countTotal/timeTotal are cumulative for the
171
+ * life of the database, not the life of the process.
172
+ */
173
+ counterFor(module, endpoint, operation) {
174
+ const key = `${module}\u001f${endpoint}\u001f${operation}`;
175
+ let counter = this.counters.get(key);
176
+ if (!counter) {
177
+ counter = {
178
+ module: module, endpoint: endpoint, operation: operation,
179
+ countDelta: 0, timeDelta: 0, countTotal: 0, timeTotal: 0
180
+ };
181
+ this.counters.set(key, counter);
182
+ }
183
+ return counter;
184
+ }
185
+
186
+ /**
187
+ * Count one request. Modules go through their own ModuleStats, which fills
188
+ * in module (and, for tx, the endpoint) for them.
189
+ *
190
+ * @param {string} name - the operation, e.g. '$expand' or 'login'
191
+ * @param {number} tat - turnaround time in ms
192
+ * @param {string} module - the module that served it
193
+ * @param {string} endpoint - the endpoint within the module, where the
194
+ * module has more than one (the tx module does; nothing else does yet)
195
+ */
196
+ countRequest(name, tat, module = SERVER_MODULE, endpoint = NO_ENDPOINT) {
83
197
  this.requestCount++;
84
198
  this.requestTime = this.requestTime + tat;
199
+
200
+ const counter = this.counterFor(module || SERVER_MODULE, endpoint || NO_ENDPOINT, name || '(unnamed)');
201
+ counter.countDelta++;
202
+ counter.countTotal++;
203
+ counter.timeDelta = counter.timeDelta + tat;
204
+ counter.timeTotal = counter.timeTotal + tat;
205
+ }
206
+
207
+ /**
208
+ * Write out the counters that saw traffic this interval, and start the next
209
+ * one. The deltas are only cleared if the write succeeded, so a transient
210
+ * database problem delays the numbers rather than losing them (the interval
211
+ * they're eventually written against is longer, which is what interval_ms on
212
+ * the row is for).
213
+ */
214
+ flushCounters(now, intervalMs) {
215
+ if (!this.db) {
216
+ return 0;
217
+ }
218
+ const rows = [];
219
+ for (const counter of this.counters.values()) {
220
+ if (counter.countDelta > 0) {
221
+ rows.push({...counter});
222
+ }
223
+ }
224
+ if (rows.length === 0) {
225
+ return 0;
226
+ }
227
+ try {
228
+ this.db.write(now, intervalMs, rows);
229
+ } catch (e) {
230
+ this.log.error(`Unable to write statistics: ${e.message}`);
231
+ return 0;
232
+ }
233
+ for (const counter of this.counters.values()) {
234
+ counter.countDelta = 0;
235
+ counter.timeDelta = 0;
236
+ }
237
+ return rows.length;
85
238
  }
86
239
 
87
240
  addTask(name, frequency) {
@@ -144,6 +297,17 @@ class ServerStats {
144
297
 
145
298
  finishStats() {
146
299
  clearInterval(this.timer);
300
+ // Synchronous, so the last partial interval is on disk before server.js
301
+ // calls process.exit().
302
+ this.flushCounters(Date.now(), Date.now() - (this.lastTime || this.startTime));
303
+ if (this.db) {
304
+ try {
305
+ this.db.close();
306
+ } catch (e) {
307
+ this.log.error(`Unable to close the statistics database: ${e.message}`);
308
+ }
309
+ this.db = null;
310
+ }
147
311
  }
148
312
 
149
313
  // Live cache aggregates across all registered caching modules (defensive: a
@@ -174,4 +338,6 @@ class ServerStats {
174
338
  }
175
339
  }
176
340
  }
177
- module.exports = ServerStats;
341
+ module.exports = ServerStats;
342
+ module.exports.ModuleStats = ModuleStats;
343
+ module.exports.SERVER_MODULE = SERVER_MODULE;
@@ -1101,7 +1101,10 @@ Unable_to_resolve_system__value_set_has_multiple_matches = The System URI could
1101
1101
  Unable_to_resolve_system__value_set_has_no_includes_or_expansion = The System URI could not be determined for the code ''{0}'' in the ValueSet ''{1}'': value set has no includes or expansion
1102
1102
  UNABLE_TO_RESOLVE_SYSTEM_SYSTEM_IS_INDETERMINATE = The code system {1} referred to from value set {0} has a grammar, and the code might be valid in it
1103
1103
  Unable_to_resolve_value_Set_ = A definition for the value Set ''{0}'' could not be found
1104
- CACHE_ID_UNKNOWN = The cache ''{0}'' is not known to this server. Caches are created with $cache-control?mode=start; this one was never created, or has expired or been released
1104
+ CACHE_ID_UNKNOWN = The cache ''{0}'' was never issued by this server, and this server has no record of it ever existing. Caches are created with $cache-control?mode=start, and a cache-id is only valid on the server instance and endpoint that issued it - either it was issued somewhere else, or this server has restarted since it was issued
1105
+ CACHE_ID_CLOSED = The cache ''{0}'' was closed {1} ago by the client, using $cache-control?mode=end (it had been open for {2}). It did not expire and it was not idle: something in the client released this cache while this request was still using it
1106
+ CACHE_ID_EXPIRED = The cache ''{0}'' expired: it went {1} without being used, and this server closes caches that have been idle for more than {2} (this one was dropped {3} ago). Every request that uses a cache resets its idle clock, so a client that keeps using a cache will not see this
1107
+ CACHE_ID_CLEARED = The cache ''{0}'' was dropped {1} ago, when this server cleared all of its caches
1105
1108
  Unable_to_validate_code_without_using_server = Unable to validate code without using server because: {0}
1106
1109
  Undefined_attribute__on__for_type__properties__ = Undefined attribute ''@{0}'' on {1} for type {2}
1107
1110
  Undefined_element_ = Undefined element ''{0}'' at {1}
@@ -1122,6 +1125,7 @@ NO_CODE_PROVIDED_VERSION = No code provided for CodeSystem ''{0}'' version ''{1}
1122
1125
  UNKNOWN_CODESYSTEM = A definition for CodeSystem ''{0}'' could not be found, so the code cannot be validated
1123
1126
  UNKNOWN_CODESYSTEM_VERSION = A definition for CodeSystem ''{0}'' version ''{1}'' could not be found, so the code cannot be validated. Valid versions: {2}
1124
1127
  UNKNOWN_CODESYSTEM_VERSION_NONE = A definition for CodeSystem ''{0}'' version ''{1}'' could not be found, so the code cannot be validated. No versions of this code system are known
1128
+ UNKNOWN_CODESYSTEM_CODING_NOT_CHECKED = A definition for the code system ''{0}'' could not be found, so this coding was not checked. The CodeableConcept is still valid: another coding is in the value set
1125
1129
  UNKNOWN_CODESYSTEM_EXP = A definition for CodeSystem ''{0}'' could not be found, so the value set cannot be expanded
1126
1130
  UNKNOWN_CODESYSTEM_VERSION_EXP = A definition for CodeSystem ''{0}'' version ''{1}'' could not be found, so the value set cannot be expanded. Valid versions: {2}
1127
1131
  UNKNOWN_CODESYSTEM_VERSION_EXP_NONE = A definition for CodeSystem ''{0}'' version ''{1}'' could not be found, so the value set cannot be expanded. No versions of this code system are known
package/tx/README.md CHANGED
@@ -278,6 +278,27 @@ Loads OMOP (Observational Medical Outcomes Partnership) vocabulary mappings from
278
278
  ```
279
279
  The file is built by importing OMOP (see [documentation](importers/readme.md))
280
280
 
281
+ #### `icd11` - ICD-11
282
+
283
+ Loads ICD-11 from a SQLite database built by importing from the WHO ICD-API.
284
+
285
+ ```yaml
286
+ - icd11:icd11-2026-01.db
287
+ ```
288
+
289
+ One database holds three code systems - the MMS and ICF linearizations and the Foundation -
290
+ so a single source line registers a provider for each one it finds:
291
+
292
+ | system | url |
293
+ |---|---|
294
+ | MMS | `http://id.who.int/icd/release/11/mms` |
295
+ | ICF | `http://id.who.int/icd/release/11/icf` |
296
+ | Foundation | `http://id.who.int/icd/entity` |
297
+
298
+ The file is built by importing ICD-11 (see [documentation](importers/icd11-schema.md)). Note
299
+ that only the WHO native API can enumerate the classification; the FHIR endpoint cannot, which
300
+ is why the importer reads from the native one.
301
+
281
302
  #### `npm` - FHIR NPM Packages
282
303
 
283
304
  Loads CodeSystem, ValueSet, and ConceptMap resources from FHIR NPM packages. Packages are fetched from the FHIR package registry (packages2.fhir.org).
package/tx/cs/cs-api.js CHANGED
@@ -993,9 +993,38 @@ class CodeSystemFactoryProvider {
993
993
  }
994
994
  }
995
995
 
996
+ /**
997
+ * The error a code system provider raises when it cannot work out the subsumption
998
+ * relationship between two codes.
999
+ *
1000
+ * $subsumes has four outcome codes - equivalent, subsumes, subsumed-by, not-subsumed -
1001
+ * and none of them means "unknown", so a server that cannot decide has no honest answer
1002
+ * to give and must report an error instead. Answering `not-subsumed` would assert
1003
+ * something the server has not established, in the direction that wrongly drops a code
1004
+ * out of a cohort. See FHIR-58748.
1005
+ *
1006
+ * This is a real condition in more than one code system: SNOMED CT expressions that no
1007
+ * classifier has seen, and media types whose difference lies in a parameter the server
1008
+ * does not understand.
1009
+ *
1010
+ * 422 rather than 400 because the request is well formed and both codes are valid - the
1011
+ * server simply cannot answer it.
1012
+ *
1013
+ * @param {string} message - what could not be determined, and why
1014
+ * @returns {Error} tagged with statusCode, issueCode and txIssueType
1015
+ */
1016
+ function cannotDetermineSubsumption(message) {
1017
+ const error = new Error(message);
1018
+ error.statusCode = 422;
1019
+ error.issueCode = 'not-supported';
1020
+ error.txIssueType = 'cannot-determine';
1021
+ return error;
1022
+ }
1023
+
996
1024
  module.exports = {
997
1025
  FilterExecutionContext,
998
1026
  CodeSystemProvider,
999
1027
  CodeSystemContentMode,
1000
- CodeSystemFactoryProvider
1028
+ CodeSystemFactoryProvider,
1029
+ cannotDetermineSubsumption
1001
1030
  };
package/tx/cs/cs-api.md CHANGED
@@ -105,7 +105,7 @@ All are `async`.
105
105
  | `parent(code)` | `null` | The parent concept, for hierarchical code systems. |
106
106
  | `extensions(code)` | `null` | FHIR extensions on the concept, if any. |
107
107
  | `properties(code)` | `[]` | The defined properties for the concept. |
108
- | `incompleteValidationMessage(code)` | `null` | A message explaining why validation may be incomplete (used by SCT). |
108
+ | `incompleteValidationMessage(code)` | `null` | A message explaining why validation may be incomplete. No provider overrides this: SNOMED expressions are checked against the concept model, and the only things it cannot check are ones SNOMED itself publishes no rule for. |
109
109
  | `sameConcept(a, b)` | `false` | Returns `true` if `a` and `b` refer to the same concept (e.g. different expression forms). |
110
110
  | `isDisplay(designation)` | `false` | Called for designations not already marked with a standard display use code; return `true` to treat the designation as a display. |
111
111
  | `subsumesTest(codeA, codeB)` | `'not-subsumed'` | Returns one of `equivalent`, `subsumes`, `subsumed-by`, or `not-subsumed`. |
package/tx/cs/cs-cs.js CHANGED
@@ -1004,7 +1004,7 @@ class FhirCodeSystemProvider extends BaseCSServices {
1004
1004
  }
1005
1005
  // Supported hierarchy filters
1006
1006
  if ((prop === 'concept' || prop === 'code') &&
1007
- ['is-a', 'descendent-of', 'is-not-a', 'in', '=', 'regex'].includes(op)) {
1007
+ ['is-a', 'descendent-of', 'is-not-a', 'child-of', 'in', '=', 'regex'].includes(op)) {
1008
1008
  return true;
1009
1009
  }
1010
1010