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.
- package/CHANGELOG.md +130 -0
- package/README.md +105 -30
- package/config-template.json +130 -4
- package/extension-tracker/extension-tracker.js +111 -3
- package/extension-tracker/readme.md +3 -0
- package/ips/ai-client.js +263 -0
- package/ips/composer.js +805 -0
- package/ips/editor-schema.js +289 -0
- package/ips/editor.js +290 -0
- package/ips/fhirpath-ips.js +397 -0
- package/ips/ips-codeable-concepts.md +1742 -0
- package/ips/ips-template.html +125 -0
- package/ips/ips-valuesets.js +159 -0
- package/ips/ips.js +1736 -0
- package/ips/membership.js +179 -0
- package/ips/pipeline.js +1012 -0
- package/ips/presenter.js +974 -0
- package/ips/profile-editor.js +578 -0
- package/ips/profiles.js +506 -0
- package/ips/readme.md +460 -0
- package/ips/render.js +1482 -0
- package/ips/search-params.js +83 -0
- package/ips/search.js +667 -0
- package/ips/share.js +178 -0
- package/ips/store.js +650 -0
- package/ips/terminology.js +395 -0
- package/ips/tx-client.js +202 -0
- package/ips/valuesets/CodeSystem-absent-unknown-uv-ips.json +206 -0
- package/ips/valuesets/ValueSet-absent-or-unknown-immunizations-uv-ips.json +59 -0
- package/ips/valuesets/ValueSet-ips-allergy-code.json +59 -0
- package/ips/valuesets/ValueSet-ips-allergy-manifestation.json +356 -0
- package/ips/valuesets/ValueSet-ips-allergy-substance.json +37 -0
- package/ips/valuesets/ValueSet-ips-body-site.json +26 -0
- package/ips/valuesets/ValueSet-ips-clinical-finding-or-none.json +35 -0
- package/ips/valuesets/ValueSet-ips-clinical-finding.json +26 -0
- package/ips/valuesets/ValueSet-ips-condition-code.json +72 -0
- package/ips/valuesets/ValueSet-ips-condition-severity.json +30 -0
- package/ips/valuesets/ValueSet-ips-condition-stage.json +26 -0
- package/ips/valuesets/ValueSet-ips-exposure-route.json +26 -0
- package/ips/valuesets/ValueSet-ips-imaging-procedure.json +25 -0
- package/ips/valuesets/ValueSet-ips-immunization-status-reason.json +43 -0
- package/ips/valuesets/ValueSet-ips-international-codes.json +25 -0
- package/ips/valuesets/ValueSet-ips-medication-ingredient.json +26 -0
- package/ips/valuesets/ValueSet-ips-medication-status-reason.json +37 -0
- package/ips/valuesets/ValueSet-ips-medication.json +50 -0
- package/ips/valuesets/ValueSet-ips-observation-code.json +18 -0
- package/ips/valuesets/ValueSet-ips-observation-interpretation.json +18 -0
- package/ips/valuesets/ValueSet-ips-procedure-followup.json +51 -0
- package/ips/valuesets/ValueSet-ips-procedure-outcome.json +30 -0
- package/ips/valuesets/ValueSet-ips-procedure-reason.json +37 -0
- package/ips/valuesets/ValueSet-ips-procedure.json +116 -0
- package/ips/valuesets/ValueSet-ips-route.json +46 -0
- package/ips/valuesets/ValueSet-ips-target-disease.json +171 -0
- package/ips/valuesets/ValueSet-ips-vaccine.json +31 -0
- package/ips/valuesets/elements.json +44 -0
- package/library/daily-report.js +422 -0
- package/library/fhir-search-values.js +138 -0
- package/library/html-server.js +32 -23
- package/library/html.js +9 -0
- package/library/mailer.js +77 -0
- package/library/narrative-check.js +222 -0
- package/library/npm-audit.js +25 -0
- package/library/package-manager.js +4 -2
- package/library/sqlite-session-store.js +29 -1
- package/package.json +8 -2
- package/packages/package-crawler.js +189 -82
- package/packages/packages.js +36 -63
- package/packages/utilities/dedupe-versions.js +22 -8
- package/publisher/publisher.js +29 -0
- package/registry/auth-store.js +366 -0
- package/registry/auth.js +872 -0
- package/registry/crawler.js +48 -1
- package/registry/documentation/api.md +162 -0
- package/registry/documentation/configuration.md +93 -0
- package/registry/documentation/documents.md +74 -0
- package/registry/documentation/internals.md +151 -0
- package/registry/documentation/readme.md +100 -0
- package/registry/documentation/security.md +199 -0
- package/registry/documents.js +309 -0
- package/registry/openapi.yaml +30 -0
- package/registry/registry-template.html +1 -1
- package/registry/registry.js +247 -54
- package/registry/uptime.js +154 -0
- package/release.sh +80 -0
- package/server.js +105 -44
- package/shl/shl.js +64 -9
- package/stats-db.js +93 -0
- package/stats.js +158 -1
- package/testing/readme.md +1 -1
- package/testing/search.js +2 -108
- package/testing/store.js +5 -0
- package/testing/testing.js +20 -0
- package/token/token-template.html +5 -4
- package/token/token.js +17 -10
- package/translations/Messages.properties +9 -0
- package/tx/README.md +52 -0
- package/tx/cm/cm-database.js +62 -0
- package/tx/cm/cm-package.js +20 -3
- package/tx/cs/cs-api.js +90 -14
- package/tx/cs/cs-cs.js +142 -19
- package/tx/cs/cs-icd11.js +366 -112
- package/tx/cs/cs-icf.js +418 -0
- package/tx/cs/cs-loinc.js +79 -39
- package/tx/cs/cs-omop.js +10 -3
- package/tx/cs/cs-snomed.js +918 -55
- package/tx/cs/cs-ucum.js +6 -0
- package/tx/cs/icd11-expressions.js +191 -78
- package/tx/importers/export-icd11-codesystem.js +58 -12
- package/tx/importers/icd11-schema.md +9 -5
- package/tx/library.js +107 -6
- package/tx/ocl/README.md +62 -6
- package/tx/ocl/cm-ocl.cjs +166 -44
- package/tx/ocl/cs-ocl.cjs +163 -13
- package/tx/ocl/resolve/public-access.js +143 -0
- package/tx/ocl/resolve/reference-resolver.js +458 -0
- package/tx/ocl/vs-ocl.cjs +167 -4
- package/tx/openapi-operations.js +4 -0
- package/tx/params.js +8 -1
- package/tx/problems.js +7 -5
- package/tx/provider.js +21 -5
- package/tx/sct/documentation.md +251 -0
- package/tx/sct/ecl.js +758 -46
- package/tx/sct/expressions.js +113 -52
- package/tx/syndication/syndication-feed.js +218 -0
- package/tx/syndication/syndication-store.js +415 -0
- package/tx/tx-html.js +37 -8
- package/tx/tx.fhir.org.yml +2 -3
- package/tx/tx.js +148 -5
- package/tx/vs/vs-database.js +26 -0
- package/tx/vs/vs-package.js +42 -10
- package/tx/vs/vs-vsac.js +25 -6
- package/tx/workers/expand.js +34 -7
- package/tx/workers/lookup.js +61 -6
- package/tx/workers/metadata.js +6 -1
- package/tx/workers/subsumes.js +1 -1
- package/tx/workers/translate.js +166 -15
- package/tx/workers/validate.js +128 -6
- package/utilities/make-sct-test-extension.js +264 -0
- package/utilities/submit-test-report.js +1 -1
- package/xig/xig.js +120 -12
- package/gr.sh +0 -3
- package/registry/readme.md +0 -124
package/shl/shl.js
CHANGED
|
@@ -808,16 +808,26 @@ class SHLModule {
|
|
|
808
808
|
}
|
|
809
809
|
});
|
|
810
810
|
|
|
811
|
-
// Helper function for the shared access logic
|
|
811
|
+
// Helper function for the shared access logic.
|
|
812
|
+
//
|
|
813
|
+
// POST is the manifest request. GET is only defined by the SHL spec for the
|
|
814
|
+
// U flag (direct file access): the client does GET {url}?recipient=... and
|
|
815
|
+
// expects the single encrypted file (JWE) back, not a manifest. The flag is
|
|
816
|
+
// not sent to or stored on the server, so a GET is taken to mean U. If the
|
|
817
|
+
// SHL has more than one file (U isn't valid for it), or it's a VHL, GET
|
|
818
|
+
// keeps returning the manifest / VHL bundle as before.
|
|
812
819
|
const handleSHLAccess = (req, res) => {
|
|
813
820
|
const start = Date.now();
|
|
814
821
|
try {
|
|
815
822
|
const {uuid} = req.params;
|
|
823
|
+
const isGet = req.method === 'GET';
|
|
816
824
|
|
|
817
825
|
let recipient, embeddedLengthMax;
|
|
818
826
|
|
|
819
|
-
if (
|
|
820
|
-
recipient = '
|
|
827
|
+
if (isGet) {
|
|
828
|
+
recipient = typeof req.query.recipient === 'string' && req.query.recipient.length > 0
|
|
829
|
+
? req.query.recipient.substring(0, 255)
|
|
830
|
+
: 'anonymous';
|
|
821
831
|
embeddedLengthMax = undefined;
|
|
822
832
|
} else {
|
|
823
833
|
({recipient, embeddedLengthMax} = req.body);
|
|
@@ -846,7 +856,7 @@ class SHLModule {
|
|
|
846
856
|
|
|
847
857
|
const logAccessSql = 'INSERT INTO SHLViews (shl_uuid, recipient, ip_address) VALUES (?, ?, ?)';
|
|
848
858
|
|
|
849
|
-
this.db.run(logAccessSql, [uuid, recipient, clientIP],
|
|
859
|
+
this.db.run(logAccessSql, [uuid, recipient, clientIP], (logErr) => {
|
|
850
860
|
if (logErr) {
|
|
851
861
|
shlLog.error('Failed to log SHL access:', logErr.message);
|
|
852
862
|
}
|
|
@@ -858,24 +868,37 @@ class SHLModule {
|
|
|
858
868
|
return res.status(500).json({error: 'Failed to retrieve files'});
|
|
859
869
|
}
|
|
860
870
|
|
|
871
|
+
// U flag: return the one file directly
|
|
872
|
+
if (isGet && !shlRow.vhl && fileRows.length === 1) {
|
|
873
|
+
try {
|
|
874
|
+
res.set('Content-Type', 'application/jose');
|
|
875
|
+
return res.send(Buffer.from(fileRows[0].cnt, 'base64'));
|
|
876
|
+
} catch (decodeErr) {
|
|
877
|
+
return res.status(500).json({error: 'Failed to decode file content'});
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// SHL links are always https; the server always sits behind an https proxy
|
|
861
882
|
const host = req.get('host') || 'localhost:3000';
|
|
862
|
-
const protocol = req.secure ? 'https' : 'http';
|
|
863
883
|
const maxLength = embeddedLengthMax ? parseInt(embeddedLengthMax) : undefined;
|
|
864
884
|
|
|
865
885
|
const files = fileRows.map(file => {
|
|
866
886
|
const fileResponse = {
|
|
867
887
|
contentType: file.type,
|
|
868
|
-
location:
|
|
888
|
+
location: `https://${host}/shl/file/${file.id}`
|
|
869
889
|
};
|
|
870
890
|
|
|
871
|
-
|
|
872
|
-
|
|
891
|
+
// cnt is stored base64-encoded; embedded must be the JWE compact serialization itself
|
|
892
|
+
const jwe = Buffer.from(file.cnt, 'base64').toString('utf8');
|
|
893
|
+
if (maxLength === undefined || jwe.length <= maxLength) {
|
|
894
|
+
fileResponse.embedded = jwe;
|
|
873
895
|
}
|
|
874
896
|
|
|
875
897
|
return fileResponse;
|
|
876
898
|
});
|
|
877
899
|
|
|
878
|
-
|
|
900
|
+
// finalized: content is fixed for the life of the link (expired links 404 above)
|
|
901
|
+
const standardResponse = {status: 'finalized', files};
|
|
879
902
|
|
|
880
903
|
if (shlRow.vhl && vhlProcessor) {
|
|
881
904
|
try {
|
|
@@ -1059,6 +1082,38 @@ class SHLModule {
|
|
|
1059
1082
|
});
|
|
1060
1083
|
}
|
|
1061
1084
|
|
|
1085
|
+
/**
|
|
1086
|
+
* The paragraph for the server's daily report: links (QR codes) created in
|
|
1087
|
+
* the period, in either form, how often links were accessed, and the
|
|
1088
|
+
* documents stored behind them.
|
|
1089
|
+
*/
|
|
1090
|
+
async dailyReport({since, now}) {
|
|
1091
|
+
const {plural} = require('../library/daily-report');
|
|
1092
|
+
if (!this.db) {
|
|
1093
|
+
return 'The database is not open.';
|
|
1094
|
+
}
|
|
1095
|
+
const get = (sql, params) => new Promise((resolve, reject) =>
|
|
1096
|
+
this.db.get(sql, params, (err, row) => err ? reject(err) : resolve(row)));
|
|
1097
|
+
// created_at is CURRENT_TIMESTAMP: UTC, 'YYYY-MM-DD HH:MM:SS'
|
|
1098
|
+
const utc = ms => new Date(ms).toISOString().replace('T', ' ').substring(0, 19);
|
|
1099
|
+
try {
|
|
1100
|
+
const created = await get(
|
|
1101
|
+
'SELECT SUM(CASE WHEN vhl THEN 0 ELSE 1 END) AS shl, SUM(CASE WHEN vhl THEN 1 ELSE 0 END) AS vhl FROM SHL WHERE created_at > ? AND created_at <= ?',
|
|
1102
|
+
[utc(since), utc(now)]);
|
|
1103
|
+
const views = await get('SELECT COUNT(*) AS n FROM SHLViews WHERE created_at > ? AND created_at <= ?', [utc(since), utc(now)]);
|
|
1104
|
+
const files = await get('SELECT COUNT(*) AS n, COUNT(DISTINCT shl_uuid) AS links FROM SHLFiles', []);
|
|
1105
|
+
const shl = created.shl || 0;
|
|
1106
|
+
const vhl = created.vhl || 0;
|
|
1107
|
+
const total = shl + vhl;
|
|
1108
|
+
const sentences = [];
|
|
1109
|
+
sentences.push(`${plural(total, 'QR code')} ${total === 1 ? 'was' : 'were'} served (${shl} SMART Health ${shl === 1 ? 'Link' : 'Links'}, ${plural(vhl, 'Verifiable Health Link')}), and links were accessed ${plural(views.n, 'time')}.`);
|
|
1110
|
+
sentences.push(`${plural(files.n, 'document')} ${files.n === 1 ? 'is' : 'are'} stored to serve ${plural(files.links, 'QR code')}.`);
|
|
1111
|
+
return sentences.join(' ');
|
|
1112
|
+
} catch (e) {
|
|
1113
|
+
return `The database could not be read: ${e.message}.`;
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1062
1117
|
async shutdown() {
|
|
1063
1118
|
shlLog.info('Shutting down SHL module...');
|
|
1064
1119
|
|
package/stats-db.js
CHANGED
|
@@ -77,6 +77,22 @@ class StatsDatabase {
|
|
|
77
77
|
this.db.exec(`CREATE INDEX IF NOT EXISTS idx_request_counts_time ON request_counts(time)`);
|
|
78
78
|
this.db.exec(`CREATE INDEX IF NOT EXISTS idx_request_counts_key ON request_counts(module, endpoint, operation, id)`);
|
|
79
79
|
|
|
80
|
+
// One row per run of the server: when it started serving, and the last
|
|
81
|
+
// time it was known to be alive. See ServerStats.overallUptime().
|
|
82
|
+
this.db.exec(`
|
|
83
|
+
CREATE TABLE IF NOT EXISTS server_runs (
|
|
84
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
85
|
+
started INTEGER NOT NULL,
|
|
86
|
+
last_seen INTEGER NOT NULL,
|
|
87
|
+
version TEXT
|
|
88
|
+
)
|
|
89
|
+
`);
|
|
90
|
+
// version was added after the table first shipped
|
|
91
|
+
if (!this.db.prepare(`PRAGMA table_info(server_runs)`).all().some(c => c.name === 'version')) {
|
|
92
|
+
this.db.exec(`ALTER TABLE server_runs ADD COLUMN version TEXT`);
|
|
93
|
+
}
|
|
94
|
+
this.touchRunStatement = this.db.prepare(`UPDATE server_runs SET last_seen = ? WHERE id = ?`);
|
|
95
|
+
|
|
80
96
|
this.insertStatement = this.db.prepare(`
|
|
81
97
|
INSERT INTO request_counts
|
|
82
98
|
(time, interval_ms, module, endpoint, operation, count_delta, count_total, time_delta, time_total)
|
|
@@ -106,6 +122,83 @@ class StatsDatabase {
|
|
|
106
122
|
`).all();
|
|
107
123
|
}
|
|
108
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Record that a new run of the server has started serving, and return its
|
|
127
|
+
* row id for touchRun().
|
|
128
|
+
*/
|
|
129
|
+
startRun(now, version = null) {
|
|
130
|
+
if (!this.db) {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
return this.db.prepare(`INSERT INTO server_runs (started, last_seen, version) VALUES (?, ?, ?)`).run(now, now, version).lastInsertRowid;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The runs that were alive at any point since `since`, oldest first, plus
|
|
138
|
+
* the last run that ended before then (so a caller can tell whether the
|
|
139
|
+
* first run in the period was an upgrade). Each is {id, started, lastSeen,
|
|
140
|
+
* version}.
|
|
141
|
+
*/
|
|
142
|
+
loadRuns(since) {
|
|
143
|
+
if (!this.db) {
|
|
144
|
+
return [];
|
|
145
|
+
}
|
|
146
|
+
const before = this.db.prepare(`
|
|
147
|
+
SELECT id, started, last_seen AS lastSeen, version FROM server_runs
|
|
148
|
+
WHERE last_seen < ? ORDER BY last_seen DESC LIMIT 1
|
|
149
|
+
`).get(since);
|
|
150
|
+
const during = this.db.prepare(`
|
|
151
|
+
SELECT id, started, last_seen AS lastSeen, version FROM server_runs
|
|
152
|
+
WHERE last_seen >= ? ORDER BY started
|
|
153
|
+
`).all(since);
|
|
154
|
+
return before ? [before, ...during] : during;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The server is still alive: move the run's last_seen forward.
|
|
159
|
+
*/
|
|
160
|
+
touchRun(runId, now) {
|
|
161
|
+
if (this.db && runId != null) {
|
|
162
|
+
this.touchRunStatement.run(now, runId);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* How long the server has been running over all the runs recorded before
|
|
168
|
+
* this one: the earliest start, and the total time between each run's start
|
|
169
|
+
* and the last time it was seen alive. {firstStart: null, runningMs: 0} if
|
|
170
|
+
* there are no earlier runs.
|
|
171
|
+
*/
|
|
172
|
+
loadRunHistory(excludeRunId) {
|
|
173
|
+
if (!this.db) {
|
|
174
|
+
return {firstStart: null, runningMs: 0};
|
|
175
|
+
}
|
|
176
|
+
const row = this.db.prepare(`
|
|
177
|
+
SELECT MIN(started) AS firstStart, COALESCE(SUM(last_seen - started), 0) AS runningMs
|
|
178
|
+
FROM server_runs
|
|
179
|
+
WHERE id IS NOT ?
|
|
180
|
+
`).get(excludeRunId == null ? -1 : excludeRunId);
|
|
181
|
+
return {firstStart: row.firstStart, runningMs: row.runningMs};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The requests counted against a module in (since, now], by endpoint and
|
|
186
|
+
* operation: [{endpoint, operation, count}]. A row is attributed to the
|
|
187
|
+
* time it was written, so the edges of the period are as fine as the
|
|
188
|
+
* collection interval (and ServerStats.flushNow() sharpens the near edge).
|
|
189
|
+
*/
|
|
190
|
+
countsSince(since, now, module) {
|
|
191
|
+
if (!this.db) {
|
|
192
|
+
return [];
|
|
193
|
+
}
|
|
194
|
+
return this.db.prepare(`
|
|
195
|
+
SELECT endpoint, operation, SUM(count_delta) AS count
|
|
196
|
+
FROM request_counts
|
|
197
|
+
WHERE module = ? AND time > ? AND time <= ?
|
|
198
|
+
GROUP BY endpoint, operation
|
|
199
|
+
`).all(module, since, now);
|
|
200
|
+
}
|
|
201
|
+
|
|
109
202
|
/**
|
|
110
203
|
* Write one interval's worth of rows. All or nothing - if the transaction
|
|
111
204
|
* fails the caller keeps its deltas and tries again next interval.
|
package/stats.js
CHANGED
|
@@ -43,6 +43,15 @@ class ModuleStats {
|
|
|
43
43
|
get staticRequestCount() { return this.parent.staticRequestCount; }
|
|
44
44
|
get history() { return this.parent.history; }
|
|
45
45
|
get startTime() { return this.parent.startTime; }
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* What this module served in (since, now]: {total, operations: {name: count},
|
|
49
|
+
* endpoints: {endpoint: {name: count}}} - see ServerStats.countsSince(). This
|
|
50
|
+
* is what a module's dailyReport() builds its numbers from.
|
|
51
|
+
*/
|
|
52
|
+
countsSince(since, now = Date.now()) {
|
|
53
|
+
return this.parent.countsSince(since, now, this.module);
|
|
54
|
+
}
|
|
46
55
|
}
|
|
47
56
|
|
|
48
57
|
class ServerStats {
|
|
@@ -62,6 +71,13 @@ class ServerStats {
|
|
|
62
71
|
// key (module/endpoint/operation) -> counter. See countRequest().
|
|
63
72
|
counters = new Map();
|
|
64
73
|
db = null;
|
|
74
|
+
// this run's row in server_runs, and what the earlier runs add up to - see overallUptime()
|
|
75
|
+
runId = null;
|
|
76
|
+
earlierRuns = {firstStart: null, runningMs: 0};
|
|
77
|
+
// how often this run says "still alive"; a crash loses at most this much
|
|
78
|
+
// from the overall uptime figure
|
|
79
|
+
heartbeatMs = 60 * 1000;
|
|
80
|
+
heartbeat = null;
|
|
65
81
|
|
|
66
82
|
constructor(config, log) {
|
|
67
83
|
this.config = config || {};
|
|
@@ -146,16 +162,94 @@ class ServerStats {
|
|
|
146
162
|
}
|
|
147
163
|
}
|
|
148
164
|
|
|
149
|
-
markStarted() {
|
|
165
|
+
markStarted(version = null) {
|
|
150
166
|
this.started = true;
|
|
167
|
+
this.version = version;
|
|
151
168
|
this.startMem = process.memoryUsage().heapUsed;
|
|
152
169
|
this.startTime = Date.now();
|
|
153
170
|
this.lastTime = this.startTime;
|
|
154
171
|
this.eventLoopMonitor = monitorEventLoopDelay({ resolution: 20 });
|
|
155
172
|
this.eventLoopMonitor.enable();
|
|
173
|
+
this.startRun();
|
|
156
174
|
this.recordMetrics();
|
|
157
175
|
}
|
|
158
176
|
|
|
177
|
+
/**
|
|
178
|
+
* Record this run in the database and start the heartbeat that keeps its
|
|
179
|
+
* last_seen current. As with everything else here, a failure is logged and
|
|
180
|
+
* the overall uptime just isn't available for this run.
|
|
181
|
+
*/
|
|
182
|
+
startRun() {
|
|
183
|
+
if (!this.db) {
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
try {
|
|
187
|
+
this.runId = this.db.startRun(this.startTime, this.version);
|
|
188
|
+
this.earlierRuns = this.db.loadRunHistory(this.runId);
|
|
189
|
+
} catch (e) {
|
|
190
|
+
this.log.error(`Unable to record the server run: ${e.message}. Overall uptime will not be available`);
|
|
191
|
+
this.runId = null;
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
this.heartbeat = setInterval(() => this.touchRun(), this.heartbeatMs);
|
|
195
|
+
if (typeof this.heartbeat.unref === 'function') {
|
|
196
|
+
this.heartbeat.unref();
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The server's runs since `since`, oldest first, each {started, lastSeen,
|
|
202
|
+
* version, current}, preceded by the run before that (if any) - see
|
|
203
|
+
* StatsDatabase.loadRuns(). The current run is reported as seen now, not as
|
|
204
|
+
* of its last heartbeat. null if the history isn't being kept.
|
|
205
|
+
*/
|
|
206
|
+
runHistory(since, now = Date.now()) {
|
|
207
|
+
if (this.runId == null) {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
let runs;
|
|
211
|
+
try {
|
|
212
|
+
runs = this.db.loadRuns(since);
|
|
213
|
+
} catch (e) {
|
|
214
|
+
this.log.error(`Unable to read the server run history: ${e.message}`);
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
return runs.map(r => r.id === this.runId
|
|
218
|
+
? {started: r.started, lastSeen: now, version: r.version, current: true}
|
|
219
|
+
: {started: r.started, lastSeen: r.lastSeen, version: r.version, current: false});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
touchRun() {
|
|
223
|
+
if (this.db && this.runId != null) {
|
|
224
|
+
try {
|
|
225
|
+
this.db.touchRun(this.runId, Date.now());
|
|
226
|
+
} catch (e) {
|
|
227
|
+
this.log.error(`Unable to record the server heartbeat: ${e.message}`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* The proportion of time the server has been up, over every run recorded
|
|
234
|
+
* in the statistics database: time it was known to be running (each run,
|
|
235
|
+
* from its start to the last heartbeat it wrote, plus this run up to now)
|
|
236
|
+
* as a percentage of the time since the first recorded run started.
|
|
237
|
+
*
|
|
238
|
+
* Returns {percent, since} or null if the history isn't being kept.
|
|
239
|
+
*/
|
|
240
|
+
overallUptime(now = Date.now()) {
|
|
241
|
+
if (this.runId == null) {
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
const since = this.earlierRuns.firstStart == null ? this.startTime : Math.min(this.earlierRuns.firstStart, this.startTime);
|
|
245
|
+
const elapsed = now - since;
|
|
246
|
+
if (elapsed <= 0) {
|
|
247
|
+
return {percent: 100, since: since};
|
|
248
|
+
}
|
|
249
|
+
const running = this.earlierRuns.runningMs + (now - this.startTime);
|
|
250
|
+
return {percent: Math.min(100, (running * 100) / elapsed), since: since};
|
|
251
|
+
}
|
|
252
|
+
|
|
159
253
|
/**
|
|
160
254
|
* The counter for one (module, endpoint, operation), created on first use.
|
|
161
255
|
*
|
|
@@ -215,6 +309,7 @@ class ServerStats {
|
|
|
215
309
|
}
|
|
216
310
|
}
|
|
217
311
|
if (rows.length === 0) {
|
|
312
|
+
this.lastFlush = now;
|
|
218
313
|
return 0;
|
|
219
314
|
}
|
|
220
315
|
try {
|
|
@@ -227,9 +322,53 @@ class ServerStats {
|
|
|
227
322
|
counter.countDelta = 0;
|
|
228
323
|
counter.timeDelta = 0;
|
|
229
324
|
}
|
|
325
|
+
this.lastFlush = now;
|
|
230
326
|
return rows.length;
|
|
231
327
|
}
|
|
232
328
|
|
|
329
|
+
/**
|
|
330
|
+
* Write out whatever has been counted since the last flush, so that a
|
|
331
|
+
* countsSince() up to `now` sees everything. Used by the daily report.
|
|
332
|
+
*/
|
|
333
|
+
flushNow(now = Date.now()) {
|
|
334
|
+
this.flushCounters(now, now - (this.lastFlush || this.lastTime || this.startTime));
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* The requests a module served in (since, now], from the persisted rows plus
|
|
339
|
+
* anything counted since the last flush. Without the database only the
|
|
340
|
+
* unflushed counts are known, so persisted is false to say the numbers are
|
|
341
|
+
* partial.
|
|
342
|
+
*/
|
|
343
|
+
countsSince(since, now, module) {
|
|
344
|
+
const result = {total: 0, operations: {}, endpoints: {}, persisted: Boolean(this.db)};
|
|
345
|
+
const add = (endpoint, operation, count) => {
|
|
346
|
+
if (!count) {
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
result.total += count;
|
|
350
|
+
result.operations[operation] = (result.operations[operation] || 0) + count;
|
|
351
|
+
const ep = result.endpoints[endpoint] || (result.endpoints[endpoint] = {});
|
|
352
|
+
ep[operation] = (ep[operation] || 0) + count;
|
|
353
|
+
};
|
|
354
|
+
if (this.db) {
|
|
355
|
+
try {
|
|
356
|
+
for (const row of this.db.countsSince(since, now, module)) {
|
|
357
|
+
add(row.endpoint, row.operation, row.count);
|
|
358
|
+
}
|
|
359
|
+
} catch (e) {
|
|
360
|
+
this.log.error(`Unable to read request counts: ${e.message}`);
|
|
361
|
+
result.persisted = false;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
for (const counter of this.counters.values()) {
|
|
365
|
+
if (counter.module === module) {
|
|
366
|
+
add(counter.endpoint, counter.operation, counter.countDelta);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return result;
|
|
370
|
+
}
|
|
371
|
+
|
|
233
372
|
addTask(name, frequency) {
|
|
234
373
|
let info = {};
|
|
235
374
|
this.taskMap.set(name, info);
|
|
@@ -265,6 +404,19 @@ class ServerStats {
|
|
|
265
404
|
}
|
|
266
405
|
}
|
|
267
406
|
|
|
407
|
+
/**
|
|
408
|
+
* The background tasks whose last run failed: [{name, state, frequency, date}]
|
|
409
|
+
*/
|
|
410
|
+
failedTasks() {
|
|
411
|
+
const failed = [];
|
|
412
|
+
for (const [name, info] of this.taskMap) {
|
|
413
|
+
if (info.status === 'error') {
|
|
414
|
+
failed.push({name: name, state: info.state, frequency: info.frequency, date: info.date});
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
return failed;
|
|
418
|
+
}
|
|
419
|
+
|
|
268
420
|
taskDetails() {
|
|
269
421
|
if (this.taskMap.size == 0) {
|
|
270
422
|
return "";
|
|
@@ -290,6 +442,11 @@ class ServerStats {
|
|
|
290
442
|
|
|
291
443
|
finishStats() {
|
|
292
444
|
clearInterval(this.timer);
|
|
445
|
+
if (this.heartbeat) {
|
|
446
|
+
clearInterval(this.heartbeat);
|
|
447
|
+
this.heartbeat = null;
|
|
448
|
+
}
|
|
449
|
+
this.touchRun();
|
|
293
450
|
// Synchronous, so the last partial interval is on disk before server.js
|
|
294
451
|
// calls process.exit().
|
|
295
452
|
this.flushCounters(Date.now(), Date.now() - (this.lastTime || this.startTime));
|
package/testing/readme.md
CHANGED
|
@@ -31,7 +31,7 @@ shown on any page.
|
|
|
31
31
|
|
|
32
32
|
FHIRsmith's own tx test run writes its results as `test-cases-report.json` in the
|
|
33
33
|
FHIRsmith root; `node utilities/submit-test-report.js [-server url] [-token token]` sends it
|
|
34
|
-
to a /testing server (by default https://
|
|
34
|
+
to a /testing server (by default https://npm.fhir.org/testing).
|
|
35
35
|
|
|
36
36
|
## Reading and searching
|
|
37
37
|
|
package/testing/search.js
CHANGED
|
@@ -60,113 +60,7 @@ const MODIFIERS = {
|
|
|
60
60
|
// parameters that control the search rather than filter it
|
|
61
61
|
const CONTROL = new Set(['_sort', '_count', '_offset', '_summary', '_format', '_total', '_pretty']);
|
|
62
62
|
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
const DATE_RE = /^(\d{4})(?:-(\d{2})(?:-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2})(\.\d+)?)?(Z|[+-]\d{2}:\d{2})?)?)?)?$/;
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* The range of instants a FHIR date, dateTime or instant covers, as [lo, hi) in epoch
|
|
69
|
-
* milliseconds. "2026" covers the whole year; "2026-09-24T10:00:00.5Z" covers 100ms.
|
|
70
|
-
* A time with no zone is taken as UTC (FHIR requires a zone, but search values often
|
|
71
|
-
* leave it off).
|
|
72
|
-
*
|
|
73
|
-
* @param {string} value
|
|
74
|
-
* @returns {{lo: number, hi: number}|null} null if the value isn't a date
|
|
75
|
-
*/
|
|
76
|
-
function dateRange(value) {
|
|
77
|
-
if (typeof value !== 'string') {
|
|
78
|
-
return null;
|
|
79
|
-
}
|
|
80
|
-
const m = DATE_RE.exec(value.trim());
|
|
81
|
-
if (!m) {
|
|
82
|
-
return null;
|
|
83
|
-
}
|
|
84
|
-
const [, y, mo, d, h, mi, s, frac, tz] = m;
|
|
85
|
-
const year = parseInt(y, 10);
|
|
86
|
-
if (!mo) {
|
|
87
|
-
return { lo: Date.UTC(year, 0, 1), hi: Date.UTC(year + 1, 0, 1) };
|
|
88
|
-
}
|
|
89
|
-
const month = parseInt(mo, 10) - 1;
|
|
90
|
-
if (month < 0 || month > 11) {
|
|
91
|
-
return null;
|
|
92
|
-
}
|
|
93
|
-
if (!d) {
|
|
94
|
-
return { lo: Date.UTC(year, month, 1), hi: Date.UTC(year, month + 1, 1) };
|
|
95
|
-
}
|
|
96
|
-
const day = parseInt(d, 10);
|
|
97
|
-
if (day < 1 || day > new Date(Date.UTC(year, month + 1, 0)).getUTCDate()) {
|
|
98
|
-
return null;
|
|
99
|
-
}
|
|
100
|
-
if (!h) {
|
|
101
|
-
return { lo: Date.UTC(year, month, day), hi: Date.UTC(year, month, day + 1) };
|
|
102
|
-
}
|
|
103
|
-
const zone = tz || 'Z';
|
|
104
|
-
const text = `${y}-${mo}-${d}T${h}:${mi}:${s || '00'}${frac || ''}${zone}`;
|
|
105
|
-
const lo = Date.parse(text);
|
|
106
|
-
if (Number.isNaN(lo) || parseInt(h, 10) > 23 || parseInt(mi, 10) > 59 || (s && parseInt(s, 10) > 59)) {
|
|
107
|
-
return null;
|
|
108
|
-
}
|
|
109
|
-
let width;
|
|
110
|
-
if (!s) {
|
|
111
|
-
width = 60000;
|
|
112
|
-
} else if (!frac) {
|
|
113
|
-
width = 1000;
|
|
114
|
-
} else {
|
|
115
|
-
// .5 is 100ms wide, .50 10ms, .500 and beyond 1ms
|
|
116
|
-
width = Math.max(1, Math.pow(10, 3 - (frac.length - 1)));
|
|
117
|
-
}
|
|
118
|
-
return { lo, hi: lo + width };
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
function splitPrefix(value) {
|
|
122
|
-
const p = value.substring(0, 2);
|
|
123
|
-
if (PREFIXES.includes(p) && value.length > 2 && !/^[a-z]/.test(value.substring(2))) {
|
|
124
|
-
return { prefix: p, rest: value.substring(2) };
|
|
125
|
-
}
|
|
126
|
-
return { prefix: 'eq', rest: value };
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
function escapeLike(value) {
|
|
130
|
-
return value.replace(/[\\%_]/g, (c) => '\\' + c);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/**
|
|
134
|
-
* Split a parameter value on commas, honouring \, as an escaped comma.
|
|
135
|
-
*/
|
|
136
|
-
function splitValues(value) {
|
|
137
|
-
const out = [];
|
|
138
|
-
let cur = '';
|
|
139
|
-
for (let i = 0; i < value.length; i++) {
|
|
140
|
-
const c = value[i];
|
|
141
|
-
if (c === '\\' && i + 1 < value.length) {
|
|
142
|
-
cur += value[i + 1];
|
|
143
|
-
i++;
|
|
144
|
-
} else if (c === ',') {
|
|
145
|
-
out.push(cur);
|
|
146
|
-
cur = '';
|
|
147
|
-
} else {
|
|
148
|
-
cur += c;
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
out.push(cur);
|
|
152
|
-
return out.filter(v => v.length > 0);
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
function dateClause(def, prefix, range) {
|
|
156
|
-
const { lo, hi } = def;
|
|
157
|
-
switch (prefix) {
|
|
158
|
-
case 'eq': return { sql: `(${lo} >= ? AND ${hi} <= ?)`, args: [range.lo, range.hi] };
|
|
159
|
-
case 'ne': return { sql: `NOT (${lo} >= ? AND ${hi} <= ?)`, args: [range.lo, range.hi] };
|
|
160
|
-
case 'gt': return { sql: `${hi} > ?`, args: [range.hi] };
|
|
161
|
-
case 'lt': return { sql: `${lo} < ?`, args: [range.lo] };
|
|
162
|
-
case 'ge': return { sql: `${hi} > ?`, args: [range.lo] };
|
|
163
|
-
case 'le': return { sql: `${lo} < ?`, args: [range.hi] };
|
|
164
|
-
case 'sa': return { sql: `${lo} >= ?`, args: [range.hi] };
|
|
165
|
-
case 'eb': return { sql: `${hi} <= ?`, args: [range.lo] };
|
|
166
|
-
case 'ap': return { sql: `(${lo} < ? AND ${hi} > ?)`, args: [range.hi, range.lo] };
|
|
167
|
-
}
|
|
168
|
-
return null;
|
|
169
|
-
}
|
|
63
|
+
const { dateRange, splitPrefix, escapeLike, splitValues, dateClause: dateRangeClause } = require('../library/fhir-search-values');
|
|
170
64
|
|
|
171
65
|
function numberClause(col, prefix, text) {
|
|
172
66
|
if (!/^[+-]?\d+(\.\d+)?([eE][+-]?\d+)?$/.test(text)) {
|
|
@@ -230,7 +124,7 @@ function valueClause(def, modifier, value) {
|
|
|
230
124
|
case 'date': {
|
|
231
125
|
const { prefix, rest } = splitPrefix(value);
|
|
232
126
|
const range = dateRange(rest);
|
|
233
|
-
return range ?
|
|
127
|
+
return range ? dateRangeClause(def, prefix, range) : null;
|
|
234
128
|
}
|
|
235
129
|
}
|
|
236
130
|
return null;
|
package/testing/store.js
CHANGED
|
@@ -256,6 +256,11 @@ class TestReportStore {
|
|
|
256
256
|
return this.stmts.count.get().n;
|
|
257
257
|
}
|
|
258
258
|
|
|
259
|
+
/** How many reports were received in (sinceMs, nowMs] */
|
|
260
|
+
countReceived(sinceMs, nowMs) {
|
|
261
|
+
return this.db.prepare('SELECT COUNT(*) AS n FROM reports WHERE received_ms > ? AND received_ms <= ?').get(sinceMs, nowMs).n;
|
|
262
|
+
}
|
|
263
|
+
|
|
259
264
|
/**
|
|
260
265
|
* Run a parsed search (see search.js).
|
|
261
266
|
*
|
package/testing/testing.js
CHANGED
|
@@ -286,6 +286,26 @@ class TestingModule {
|
|
|
286
286
|
this.retentionTimer.unref();
|
|
287
287
|
}
|
|
288
288
|
|
|
289
|
+
/**
|
|
290
|
+
* The paragraph for the server's daily report: pages served, reports received
|
|
291
|
+
* in the period, and how many are held.
|
|
292
|
+
*/
|
|
293
|
+
dailyReport({since, now}) {
|
|
294
|
+
const {plural} = require('../library/daily-report');
|
|
295
|
+
const sentences = [];
|
|
296
|
+
if (this.stats) {
|
|
297
|
+
const counts = this.stats.countsSince(since, now);
|
|
298
|
+
sentences.push(`${plural(counts.total, 'page')} ${counts.total === 1 ? 'was' : 'were'} served.`);
|
|
299
|
+
}
|
|
300
|
+
if (this.store) {
|
|
301
|
+
const received = this.store.countReceived(since, now);
|
|
302
|
+
sentences.push(`${plural(received, 'test report')} ${received === 1 ? 'was' : 'were'} received; ${plural(this.store.count(), 'report')} ${this.store.count() === 1 ? 'is' : 'are'} held.`);
|
|
303
|
+
} else {
|
|
304
|
+
sentences.push('The report store is not open.');
|
|
305
|
+
}
|
|
306
|
+
return sentences.join(' ');
|
|
307
|
+
}
|
|
308
|
+
|
|
289
309
|
async shutdown() {
|
|
290
310
|
if (this.retentionTimer) {
|
|
291
311
|
clearInterval(this.retentionTimer);
|
|
@@ -150,23 +150,24 @@
|
|
|
150
150
|
|
|
151
151
|
<div class="api-docs">
|
|
152
152
|
<h4>API Documentation</h4>
|
|
153
|
-
<p>Use these endpoints to validate and track API key usage
|
|
153
|
+
<p>Use these endpoints to validate and track API key usage. Send the key in a header:
|
|
154
|
+
<code>Authorization: Bearer {api_key}</code> (or <code>X-API-Key: {api_key}</code>).</p>
|
|
154
155
|
|
|
155
156
|
<div class="mb-3">
|
|
156
157
|
<strong>Validate API Key:</strong>
|
|
157
|
-
<div class="api-endpoint">GET /token/api/validate
|
|
158
|
+
<div class="api-endpoint">GET /token/api/validate</div>
|
|
158
159
|
<p>Returns user information and allowed request count.</p>
|
|
159
160
|
</div>
|
|
160
161
|
|
|
161
162
|
<div class="mb-3">
|
|
162
163
|
<strong>Record Usage:</strong>
|
|
163
|
-
<div class="api-endpoint">POST /token/api/usage
|
|
164
|
+
<div class="api-endpoint">POST /token/api/usage</div>
|
|
164
165
|
<p>Records API usage. Send <code>{"count": 1}</code> in request body.</p>
|
|
165
166
|
</div>
|
|
166
167
|
|
|
167
168
|
<div class="mb-3">
|
|
168
169
|
<strong>Get Usage Statistics:</strong>
|
|
169
|
-
<div class="api-endpoint">GET /token/api/stats
|
|
170
|
+
<div class="api-endpoint">GET /token/api/stats?days=30</div>
|
|
170
171
|
<p>Returns usage statistics for the specified number of days.</p>
|
|
171
172
|
</div>
|
|
172
173
|
</div>
|