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.
- package/CHANGELOG.md +377 -2
- package/README.md +1 -1
- package/config-template.json +31 -1
- package/library/languages.js +191 -34
- package/library/path-safety.js +51 -0
- package/library/request-token.js +56 -0
- package/package.json +2 -2
- package/packages/packages.js +17 -118
- package/publisher/publisher.js +208 -30
- package/publisher/validation.js +210 -0
- package/server.js +24 -15
- package/stats-db.js +127 -0
- package/stats.js +172 -6
- package/translations/Messages.properties +5 -1
- package/tx/README.md +21 -0
- package/tx/cs/cs-api.js +30 -1
- package/tx/cs/cs-api.md +1 -1
- package/tx/cs/cs-cs.js +1 -1
- package/tx/cs/cs-icd11.js +805 -0
- package/tx/cs/cs-lang.js +331 -85
- package/tx/cs/cs-loinc.js +45 -4
- package/tx/cs/cs-mimetypes.js +492 -21
- package/tx/cs/cs-snomed.js +141 -58
- package/tx/cs/cs-ucum.js +33 -3
- package/tx/cs/icd11-expressions.js +243 -0
- package/tx/importers/export-icd11-codesystem.js +823 -0
- package/tx/importers/icd11-schema.md +285 -0
- package/tx/importers/import-icd11.js +861 -0
- package/tx/importers/import-loinc.module.js +14 -7
- package/tx/importers/import-sct.module.js +10 -1
- package/tx/library/codesystem.js +21 -27
- package/tx/library/conceptmap.js +39 -0
- package/tx/library/nesting-limit.js +79 -0
- package/tx/library/operation-outcome.js +97 -3
- package/tx/library/parameter-check.js +139 -0
- package/tx/library/valueset.js +16 -20
- package/tx/library.js +23 -1
- package/tx/ocl/cache/cache-utils.cjs +5 -3
- package/tx/ocl/cs-ocl.cjs +73 -17
- package/tx/ocl/jobs/background-queue.cjs +6 -3
- package/tx/ocl/mappers/concept-mapper.cjs +9 -1
- package/tx/ocl/shared/patches.cjs +48 -1
- package/tx/ocl/vs-ocl.cjs +15 -13
- package/tx/operation-context.js +220 -10
- package/tx/params.js +226 -175
- package/tx/sct/expressions.js +677 -15
- package/tx/tx.fhir.org.yml +3 -2
- package/tx/tx.js +72 -92
- package/tx/workers/batch-validate.js +11 -18
- package/tx/workers/batch.js +16 -28
- package/tx/workers/cache-control.js +87 -17
- package/tx/workers/closure.js +3 -8
- package/tx/workers/compare.js +6 -34
- package/tx/workers/expand.js +108 -60
- package/tx/workers/lookup.js +35 -40
- package/tx/workers/read.js +6 -40
- package/tx/workers/search.js +2 -8
- package/tx/workers/subsumes.js +58 -41
- package/tx/workers/translate.js +472 -47
- package/tx/workers/validate.js +83 -78
- package/tx/workers/worker.js +92 -11
- package/tx/xml/xml-base.js +264 -77
- package/tx/xversion/xv-conceptmap.js +81 -0
- package/tx/xversion/xv-parameters.js +8 -0
- package/tx/xversion/xv-valueset.js +39 -1
- package/utilities/merge-audit.py +178 -0
- package/utilities/why-merge-audit.md +39 -0
- 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 =
|
|
545
|
-
|
|
546
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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}''
|
|
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
|
|
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
|
|