fhirsmith 0.13.4 → 0.14.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 +32 -1
- package/README.md +16 -3
- package/config-template.json +70 -1
- package/library/cgroup-memory.js +113 -0
- package/library/folder-setup.js +8 -1
- package/nginx.md +391 -0
- package/package.json +3 -2
- package/publisher/git-remote.js +141 -0
- package/publisher/github-release.js +195 -0
- package/publisher/publisher.js +146 -14
- package/server.js +51 -14
- package/stats.js +19 -13
- package/testing/readme.md +112 -0
- package/testing/render.js +482 -0
- package/testing/search.js +378 -0
- package/testing/store.js +266 -0
- package/testing/testing-template.html +126 -0
- package/testing/testing.js +486 -0
- package/translations/Messages.properties +32 -0
- package/tx/closure/closure-store.js +396 -0
- package/tx/cs/cs-api.js +6 -1
- package/tx/cs/cs-rxnorm.js +35 -8
- package/tx/cs/cs-snomed.js +22 -3
- package/tx/html/codesystem-operations.liquid +177 -0
- package/tx/html/ecl-panel.liquid +93 -0
- package/tx/html/home-metrics.liquid +39 -76
- package/tx/html/tx-template.html +1 -0
- package/tx/library/conceptmap.js +22 -6
- package/tx/library/designations.js +12 -3
- package/tx/library/operation-outcome.js +3 -1
- package/tx/library/renderer.js +42 -5
- package/tx/operation-context.js +80 -17
- package/tx/provider.js +85 -0
- package/tx/sct/expressions.js +128 -44
- package/tx/tx-html.js +426 -44
- package/tx/tx.js +117 -9
- package/tx/workers/cache-control.js +2 -2
- package/tx/workers/closure.js +483 -13
- package/tx/workers/metadata.js +8 -3
- package/tx/workers/read.js +18 -3
- package/tx/workers/subsumes.js +18 -2
- package/tx/workers/translate.js +167 -20
- package/tx/workers/validate.js +61 -182
- package/tx/workers/worker.js +9 -0
- package/tx/xversion/xv-conceptmap.js +17 -8
- package/utilities/submit-test-report.js +150 -0
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026, Health Intersections Pty Ltd (http://www.healthintersections.com.au)
|
|
3
|
+
//
|
|
4
|
+
// Licensed under BSD-3: https://opensource.org/license/bsd-3-clause
|
|
5
|
+
//
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* FHIR search parameter handling for the /testing module.
|
|
9
|
+
*
|
|
10
|
+
* Turns a query (as express parses it: repeated parameters arrive as arrays) into SQL
|
|
11
|
+
* clauses over the reports table. None of the parameters here are formally defined as
|
|
12
|
+
* SearchParameter resources yet, so the behaviour is simply what this file does:
|
|
13
|
+
*
|
|
14
|
+
* name, tester string: starts-with, case-insensitive; :exact, :contains, :missing
|
|
15
|
+
* status, result token: exact code (a system| prefix is ignored); :not, :missing
|
|
16
|
+
* testscript uri: exact; :below (starts-with), :missing
|
|
17
|
+
* participant uri of any participant: exact; :below
|
|
18
|
+
* score number: eq ne gt lt ge le sa eb ap, with implicit precision for eq
|
|
19
|
+
* issued, _lastUpdated date: eq ne gt lt ge le sa eb ap, compared as ranges
|
|
20
|
+
* _id exact
|
|
21
|
+
* _sort any of the above (not _id), comma separated, '-' for descending
|
|
22
|
+
* _count, _offset paging; _summary=count for the total alone
|
|
23
|
+
*
|
|
24
|
+
* Comma separated values are ORed, repeated parameters are ANDed. Empty values are
|
|
25
|
+
* ignored, as the spec says. Unknown parameters and modifiers are reported back in
|
|
26
|
+
* `unknown` so the caller can decide whether to be lenient or strict; values that
|
|
27
|
+
* can't be understood (a bad date or number) are reported in `errors`, and those are
|
|
28
|
+
* always an error.
|
|
29
|
+
*
|
|
30
|
+
* @module testing/search
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const DEFAULT_COUNT = 50;
|
|
34
|
+
const MAX_COUNT = 500;
|
|
35
|
+
|
|
36
|
+
// the column each parameter searches, and the one it sorts on
|
|
37
|
+
const PARAMS = {
|
|
38
|
+
_id: { type: 'id', col: 'r.id' },
|
|
39
|
+
name: { type: 'string', col: 'r.name', lc: 'r.name_lc', sort: 'r.name_lc' },
|
|
40
|
+
tester: { type: 'string', col: 'r.tester', lc: 'r.tester_lc', sort: 'r.tester_lc' },
|
|
41
|
+
status: { type: 'token', col: 'r.status', sort: 'r.status' },
|
|
42
|
+
result: { type: 'token', col: 'r.result', sort: 'r.result' },
|
|
43
|
+
testscript: { type: 'uri', col: 'r.test_script', sort: 'r.test_script' },
|
|
44
|
+
participant: { type: 'participant' },
|
|
45
|
+
score: { type: 'number', col: 'r.score', sort: 'r.score' },
|
|
46
|
+
issued: { type: 'date', lo: 'r.issued_lo', hi: 'r.issued_hi', sort: 'r.issued_lo' },
|
|
47
|
+
_lastUpdated: { type: 'date', lo: 'r.received_ms', hi: '(r.received_ms + 1)', sort: 'r.received_ms' }
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const MODIFIERS = {
|
|
51
|
+
id: [],
|
|
52
|
+
string: ['exact', 'contains', 'missing'],
|
|
53
|
+
token: ['not', 'missing'],
|
|
54
|
+
uri: ['below', 'missing'],
|
|
55
|
+
participant: ['below'],
|
|
56
|
+
number: ['missing'],
|
|
57
|
+
date: []
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// parameters that control the search rather than filter it
|
|
61
|
+
const CONTROL = new Set(['_sort', '_count', '_offset', '_summary', '_format', '_total', '_pretty']);
|
|
62
|
+
|
|
63
|
+
const PREFIXES = ['eq', 'ne', 'gt', 'lt', 'ge', 'le', 'sa', 'eb', 'ap'];
|
|
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
|
+
}
|
|
170
|
+
|
|
171
|
+
function numberClause(col, prefix, text) {
|
|
172
|
+
if (!/^[+-]?\d+(\.\d+)?([eE][+-]?\d+)?$/.test(text)) {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
const n = Number(text);
|
|
176
|
+
// implicit precision: "90" means [89.5, 90.5), "90.0" means [89.95, 90.05)
|
|
177
|
+
const dot = text.split(/[eE]/)[0].split('.')[1];
|
|
178
|
+
const half = /[eE]/.test(text) ? 0 : 0.5 * Math.pow(10, -(dot ? dot.length : 0));
|
|
179
|
+
switch (prefix) {
|
|
180
|
+
case 'eq': return half ? { sql: `(${col} >= ? AND ${col} < ?)`, args: [n - half, n + half] } : { sql: `${col} = ?`, args: [n] };
|
|
181
|
+
case 'ne': return half ? { sql: `NOT (${col} >= ? AND ${col} < ?)`, args: [n - half, n + half] } : { sql: `${col} <> ?`, args: [n] };
|
|
182
|
+
case 'gt': case 'sa': return { sql: `${col} > ?`, args: [n] };
|
|
183
|
+
case 'lt': case 'eb': return { sql: `${col} < ?`, args: [n] };
|
|
184
|
+
case 'ge': return { sql: `${col} >= ?`, args: [n] };
|
|
185
|
+
case 'le': return { sql: `${col} <= ?`, args: [n] };
|
|
186
|
+
case 'ap': {
|
|
187
|
+
const d = Math.abs(n) * 0.1;
|
|
188
|
+
return { sql: `(${col} >= ? AND ${col} <= ?)`, args: [n - d, n + d] };
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function valueClause(def, modifier, value) {
|
|
195
|
+
switch (def.type) {
|
|
196
|
+
case 'id':
|
|
197
|
+
return { sql: `${def.col} = ?`, args: [value] };
|
|
198
|
+
case 'string': {
|
|
199
|
+
if (modifier === 'exact') {
|
|
200
|
+
return { sql: `${def.col} = ?`, args: [value] };
|
|
201
|
+
}
|
|
202
|
+
const v = escapeLike(value.toLowerCase());
|
|
203
|
+
return {
|
|
204
|
+
sql: `${def.lc} LIKE ? ESCAPE '\\'`,
|
|
205
|
+
args: [modifier === 'contains' ? `%${v}%` : `${v}%`]
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
case 'token': {
|
|
209
|
+
// system|code - these elements are plain codes, so the system is ignored
|
|
210
|
+
const code = value.includes('|') ? value.substring(value.indexOf('|') + 1) : value;
|
|
211
|
+
return { sql: modifier === 'not' ? `(${def.col} IS NULL OR ${def.col} <> ?)` : `${def.col} = ?`, args: [code] };
|
|
212
|
+
}
|
|
213
|
+
case 'uri':
|
|
214
|
+
if (modifier === 'below') {
|
|
215
|
+
return { sql: `${def.col} LIKE ? ESCAPE '\\'`, args: [escapeLike(value) + '%'] };
|
|
216
|
+
}
|
|
217
|
+
return { sql: `${def.col} = ?`, args: [value] };
|
|
218
|
+
case 'participant':
|
|
219
|
+
if (modifier === 'below') {
|
|
220
|
+
return {
|
|
221
|
+
sql: `EXISTS (SELECT 1 FROM participants p WHERE p.report_id = r.id AND p.uri LIKE ? ESCAPE '\\')`,
|
|
222
|
+
args: [escapeLike(value) + '%']
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
return { sql: 'EXISTS (SELECT 1 FROM participants p WHERE p.report_id = r.id AND p.uri = ?)', args: [value] };
|
|
226
|
+
case 'number': {
|
|
227
|
+
const { prefix, rest } = splitPrefix(value);
|
|
228
|
+
return numberClause(def.col, prefix, rest);
|
|
229
|
+
}
|
|
230
|
+
case 'date': {
|
|
231
|
+
const { prefix, rest } = splitPrefix(value);
|
|
232
|
+
const range = dateRange(rest);
|
|
233
|
+
return range ? dateClause(def, prefix, range) : null;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function asList(v) {
|
|
240
|
+
if (v === undefined || v === null) {
|
|
241
|
+
return [];
|
|
242
|
+
}
|
|
243
|
+
return (Array.isArray(v) ? v : [v]).filter(x => typeof x === 'string');
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function intParam(v, dflt) {
|
|
247
|
+
const s = asList(v)[0];
|
|
248
|
+
if (s === undefined || s === '') {
|
|
249
|
+
return dflt;
|
|
250
|
+
}
|
|
251
|
+
return /^\d+$/.test(s) ? parseInt(s, 10) : NaN;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Parse a search.
|
|
256
|
+
*
|
|
257
|
+
* @param {Object} query - parsed query string; values are strings or arrays of strings
|
|
258
|
+
* @returns {{
|
|
259
|
+
* where: string[], args: any[], orderBy: string, count: number, offset: number,
|
|
260
|
+
* summaryCount: boolean, used: Array<[string,string]>, sort: string|null,
|
|
261
|
+
* unknown: string[], errors: string[]
|
|
262
|
+
* }}
|
|
263
|
+
*/
|
|
264
|
+
function parseSearch(query) {
|
|
265
|
+
const result = {
|
|
266
|
+
where: [], args: [], orderBy: 'r.received_ms DESC, r.rowid DESC', count: DEFAULT_COUNT, offset: 0,
|
|
267
|
+
summaryCount: false, used: [], sort: null, unknown: [], errors: []
|
|
268
|
+
};
|
|
269
|
+
for (const [key, raw] of Object.entries(query || {})) {
|
|
270
|
+
if (CONTROL.has(key)) {
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
const colon = key.indexOf(':');
|
|
274
|
+
const name = colon < 0 ? key : key.substring(0, colon);
|
|
275
|
+
const modifier = colon < 0 ? null : key.substring(colon + 1);
|
|
276
|
+
const def = Object.prototype.hasOwnProperty.call(PARAMS, name) ? PARAMS[name] : null;
|
|
277
|
+
if (!def) {
|
|
278
|
+
result.unknown.push(key);
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
if (modifier !== null && !MODIFIERS[def.type].includes(modifier)) {
|
|
282
|
+
result.unknown.push(key);
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
for (const value of asList(raw)) {
|
|
286
|
+
if (value === '') {
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
if (modifier === 'missing') {
|
|
290
|
+
if (value !== 'true' && value !== 'false') {
|
|
291
|
+
result.errors.push(`${key}: '${value}' is not true or false`);
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
result.where.push(`${def.col} IS ${value === 'true' ? '' : 'NOT '}NULL`);
|
|
295
|
+
result.used.push([key, value]);
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
const clauses = [];
|
|
299
|
+
for (const v of splitValues(value)) {
|
|
300
|
+
const c = valueClause(def, modifier, v);
|
|
301
|
+
if (!c) {
|
|
302
|
+
result.errors.push(`${key}: '${v}' is not a valid ${def.type}`);
|
|
303
|
+
} else {
|
|
304
|
+
clauses.push(c);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (clauses.length > 0) {
|
|
308
|
+
result.where.push(clauses.length === 1 ? clauses[0].sql : '(' + clauses.map(c => c.sql).join(' OR ') + ')');
|
|
309
|
+
for (const c of clauses) {
|
|
310
|
+
result.args.push(...c.args);
|
|
311
|
+
}
|
|
312
|
+
result.used.push([key, value]);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// _sort
|
|
318
|
+
const sortSpec = asList(query && query._sort).join(',');
|
|
319
|
+
if (sortSpec) {
|
|
320
|
+
const parts = [];
|
|
321
|
+
for (const item of sortSpec.split(',').map(s => s.trim()).filter(Boolean)) {
|
|
322
|
+
const desc = item.startsWith('-');
|
|
323
|
+
const name = desc ? item.substring(1) : item;
|
|
324
|
+
const def = Object.prototype.hasOwnProperty.call(PARAMS, name) ? PARAMS[name] : null;
|
|
325
|
+
if (!def || !def.sort) {
|
|
326
|
+
result.errors.push(`_sort: cannot sort on '${name}'`);
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
parts.push(`${def.sort} ${desc ? 'DESC' : 'ASC'}`);
|
|
330
|
+
}
|
|
331
|
+
if (parts.length > 0) {
|
|
332
|
+
result.orderBy = parts.join(', ') + ', r.received_ms DESC, r.rowid DESC';
|
|
333
|
+
result.sort = sortSpec;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const count = intParam(query && query._count, DEFAULT_COUNT);
|
|
338
|
+
if (Number.isNaN(count)) {
|
|
339
|
+
result.errors.push('_count must be a non-negative integer');
|
|
340
|
+
} else {
|
|
341
|
+
result.count = Math.min(count, MAX_COUNT);
|
|
342
|
+
}
|
|
343
|
+
const offset = intParam(query && query._offset, 0);
|
|
344
|
+
if (Number.isNaN(offset)) {
|
|
345
|
+
result.errors.push('_offset must be a non-negative integer');
|
|
346
|
+
} else {
|
|
347
|
+
result.offset = offset;
|
|
348
|
+
}
|
|
349
|
+
result.summaryCount = asList(query && query._summary)[0] === 'count' || result.count === 0;
|
|
350
|
+
return result;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* The search parameters, for the CapabilityStatement.
|
|
355
|
+
*/
|
|
356
|
+
function capabilitySearchParams() {
|
|
357
|
+
const docs = {
|
|
358
|
+
_id: 'The logical id of the report',
|
|
359
|
+
name: 'TestReport.name - starts with, case insensitive; :exact, :contains',
|
|
360
|
+
tester: 'TestReport.tester - starts with, case insensitive; :exact, :contains',
|
|
361
|
+
status: 'TestReport.status',
|
|
362
|
+
result: 'TestReport.result',
|
|
363
|
+
testscript: 'TestReport.testScript (canonical, or R4 reference); :below for starts with',
|
|
364
|
+
participant: 'TestReport.participant.uri - any participant; :below for starts with',
|
|
365
|
+
score: 'TestReport.score',
|
|
366
|
+
issued: 'TestReport.issued',
|
|
367
|
+
_lastUpdated: 'When the report was received by this server'
|
|
368
|
+
};
|
|
369
|
+
const types = { id: 'token', string: 'string', token: 'token', uri: 'uri', participant: 'uri', number: 'number', date: 'date' };
|
|
370
|
+
return Object.entries(PARAMS).map(([name, def]) => ({
|
|
371
|
+
name, type: types[def.type], documentation: docs[name]
|
|
372
|
+
}));
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
module.exports = {
|
|
376
|
+
parseSearch, dateRange, splitValues, capabilitySearchParams,
|
|
377
|
+
PARAMS, DEFAULT_COUNT, MAX_COUNT
|
|
378
|
+
};
|
package/testing/store.js
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026, Health Intersections Pty Ltd (http://www.healthintersections.com.au)
|
|
3
|
+
//
|
|
4
|
+
// Licensed under BSD-3: https://opensource.org/license/bsd-3-clause
|
|
5
|
+
//
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* SQLite storage for received TestReports.
|
|
9
|
+
*
|
|
10
|
+
* Each report is kept whole, as the JSON that was stored (with the server's id and
|
|
11
|
+
* meta.lastUpdated), alongside the columns the searches use. Reports are never edited
|
|
12
|
+
* once received, so there is no versioning. The submitter's IP address is recorded for
|
|
13
|
+
* tracing abuse, and is never returned by anything in this module.
|
|
14
|
+
*
|
|
15
|
+
* @module testing/store
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const Database = require('better-sqlite3');
|
|
19
|
+
const { dateRange } = require('./search');
|
|
20
|
+
|
|
21
|
+
const SCHEMA = `
|
|
22
|
+
CREATE TABLE IF NOT EXISTS reports (
|
|
23
|
+
id TEXT PRIMARY KEY,
|
|
24
|
+
received TEXT NOT NULL, -- meta.lastUpdated
|
|
25
|
+
received_ms INTEGER NOT NULL,
|
|
26
|
+
ip TEXT, -- never shared
|
|
27
|
+
name TEXT NOT NULL,
|
|
28
|
+
name_lc TEXT NOT NULL,
|
|
29
|
+
status TEXT NOT NULL,
|
|
30
|
+
result TEXT NOT NULL,
|
|
31
|
+
score REAL,
|
|
32
|
+
tester TEXT NOT NULL,
|
|
33
|
+
tester_lc TEXT NOT NULL,
|
|
34
|
+
test_script TEXT,
|
|
35
|
+
issued TEXT NOT NULL,
|
|
36
|
+
issued_lo INTEGER NOT NULL,
|
|
37
|
+
issued_hi INTEGER NOT NULL,
|
|
38
|
+
size INTEGER NOT NULL,
|
|
39
|
+
json TEXT NOT NULL
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
CREATE TABLE IF NOT EXISTS participants (
|
|
43
|
+
report_id TEXT NOT NULL REFERENCES reports(id) ON DELETE CASCADE,
|
|
44
|
+
seq INTEGER NOT NULL,
|
|
45
|
+
type TEXT,
|
|
46
|
+
uri TEXT NOT NULL,
|
|
47
|
+
version TEXT,
|
|
48
|
+
display TEXT
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
CREATE INDEX IF NOT EXISTS idx_reports_received ON reports(received_ms);
|
|
52
|
+
CREATE INDEX IF NOT EXISTS idx_reports_name ON reports(name_lc);
|
|
53
|
+
CREATE INDEX IF NOT EXISTS idx_reports_tester ON reports(tester_lc);
|
|
54
|
+
CREATE INDEX IF NOT EXISTS idx_reports_status ON reports(status);
|
|
55
|
+
CREATE INDEX IF NOT EXISTS idx_reports_result ON reports(result);
|
|
56
|
+
CREATE INDEX IF NOT EXISTS idx_reports_score ON reports(score);
|
|
57
|
+
CREATE INDEX IF NOT EXISTS idx_reports_script ON reports(test_script);
|
|
58
|
+
CREATE INDEX IF NOT EXISTS idx_reports_issued ON reports(issued_lo, issued_hi);
|
|
59
|
+
CREATE INDEX IF NOT EXISTS idx_participants_report ON participants(report_id);
|
|
60
|
+
CREATE INDEX IF NOT EXISTS idx_participants_uri ON participants(uri);
|
|
61
|
+
`;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* TestReport.testScript is a canonical in R5 and a Reference in R4; either way we want
|
|
65
|
+
* the URL.
|
|
66
|
+
*/
|
|
67
|
+
function testScriptOf(report) {
|
|
68
|
+
const ts = report.testScript;
|
|
69
|
+
if (typeof ts === 'string') {
|
|
70
|
+
return ts;
|
|
71
|
+
}
|
|
72
|
+
if (ts && typeof ts === 'object') {
|
|
73
|
+
if (typeof ts.reference === 'string') {
|
|
74
|
+
return ts.reference;
|
|
75
|
+
}
|
|
76
|
+
if (ts.identifier && typeof ts.identifier.value === 'string') {
|
|
77
|
+
return ts.identifier.value;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function str(v) {
|
|
84
|
+
return typeof v === 'string' ? v : null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
class TestReportStore {
|
|
88
|
+
/**
|
|
89
|
+
* @param {string} dbPath - file name, or ':memory:'
|
|
90
|
+
*/
|
|
91
|
+
constructor(dbPath) {
|
|
92
|
+
this.db = new Database(dbPath);
|
|
93
|
+
this.db.pragma('journal_mode = WAL');
|
|
94
|
+
this.db.pragma('foreign_keys = ON');
|
|
95
|
+
this.db.exec(SCHEMA);
|
|
96
|
+
// participants.version was added after the first databases were made
|
|
97
|
+
if (!this.db.prepare('PRAGMA table_info(participants)').all().some(c => c.name === 'version')) {
|
|
98
|
+
this.db.exec('ALTER TABLE participants ADD COLUMN version TEXT');
|
|
99
|
+
}
|
|
100
|
+
this.stmts = {
|
|
101
|
+
insert: this.db.prepare(`
|
|
102
|
+
INSERT INTO reports (id, received, received_ms, ip, name, name_lc, status, result, score,
|
|
103
|
+
tester, tester_lc, test_script, issued, issued_lo, issued_hi, size, json)
|
|
104
|
+
VALUES (@id, @received, @received_ms, @ip, @name, @name_lc, @status, @result, @score,
|
|
105
|
+
@tester, @tester_lc, @test_script, @issued, @issued_lo, @issued_hi, @size, @json)`),
|
|
106
|
+
insertParticipant: this.db.prepare(
|
|
107
|
+
'INSERT INTO participants (report_id, seq, type, uri, version, display) VALUES (?, ?, ?, ?, ?, ?)'),
|
|
108
|
+
read: this.db.prepare('SELECT json FROM reports WHERE id = ?'),
|
|
109
|
+
delete: this.db.prepare('DELETE FROM reports WHERE id = ?'),
|
|
110
|
+
purge: this.db.prepare('DELETE FROM reports WHERE received_ms < ?'),
|
|
111
|
+
count: this.db.prepare('SELECT COUNT(*) AS n FROM reports')
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
close() {
|
|
116
|
+
if (this.db) {
|
|
117
|
+
this.db.close();
|
|
118
|
+
this.db = null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Store a report that has already been validated and given its id and meta.lastUpdated.
|
|
124
|
+
*
|
|
125
|
+
* @param {Object} report
|
|
126
|
+
* @param {{ip?: string, receivedMs: number}} info
|
|
127
|
+
*/
|
|
128
|
+
insert(report, info) {
|
|
129
|
+
const json = JSON.stringify(report);
|
|
130
|
+
const issued = dateRange(report.issued);
|
|
131
|
+
const row = {
|
|
132
|
+
id: report.id,
|
|
133
|
+
received: report.meta.lastUpdated,
|
|
134
|
+
received_ms: info.receivedMs,
|
|
135
|
+
ip: info.ip || null,
|
|
136
|
+
name: report.name,
|
|
137
|
+
name_lc: report.name.toLowerCase(),
|
|
138
|
+
status: report.status,
|
|
139
|
+
result: report.result,
|
|
140
|
+
score: typeof report.score === 'number' && Number.isFinite(report.score) ? report.score : null,
|
|
141
|
+
tester: report.tester,
|
|
142
|
+
tester_lc: report.tester.toLowerCase(),
|
|
143
|
+
test_script: testScriptOf(report),
|
|
144
|
+
issued: report.issued,
|
|
145
|
+
issued_lo: issued.lo,
|
|
146
|
+
issued_hi: issued.hi,
|
|
147
|
+
size: Buffer.byteLength(json, 'utf8'),
|
|
148
|
+
json
|
|
149
|
+
};
|
|
150
|
+
this.db.transaction(() => {
|
|
151
|
+
this.stmts.insert.run(row);
|
|
152
|
+
report.participant.forEach((p, i) => {
|
|
153
|
+
this.stmts.insertParticipant.run(report.id, i, str(p.type), p.uri, str(p.version), str(p.display));
|
|
154
|
+
});
|
|
155
|
+
})();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** @returns {Object|null} the stored report */
|
|
159
|
+
read(id) {
|
|
160
|
+
const row = this.stmts.read.get(id);
|
|
161
|
+
return row ? JSON.parse(row.json) : null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** @returns {boolean} whether there was such a report */
|
|
165
|
+
delete(id) {
|
|
166
|
+
return this.stmts.delete.run(id).changes > 0;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Delete reports received before the given time. @returns {number} how many */
|
|
170
|
+
purgeBefore(ms) {
|
|
171
|
+
return this.stmts.purge.run(ms).changes;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
count() {
|
|
175
|
+
return this.stmts.count.get().n;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Run a parsed search (see search.js).
|
|
180
|
+
*
|
|
181
|
+
* @param {Object} search - from parseSearch
|
|
182
|
+
* @param {{withJson?: boolean}} options - include the stored JSON (FHIR API), or just
|
|
183
|
+
* the columns the HTML list needs
|
|
184
|
+
* @returns {{total: number, rows: Object[]}}
|
|
185
|
+
*/
|
|
186
|
+
search(search, options = {}) {
|
|
187
|
+
const where = search.where.length > 0 ? 'WHERE ' + search.where.join(' AND ') : '';
|
|
188
|
+
const total = this.db.prepare(`SELECT COUNT(*) AS n FROM reports r ${where}`).get(...search.args).n;
|
|
189
|
+
if (search.summaryCount || search.count === 0) {
|
|
190
|
+
return { total, rows: [] };
|
|
191
|
+
}
|
|
192
|
+
const cols = options.withJson
|
|
193
|
+
? 'r.id, r.json'
|
|
194
|
+
: 'r.id, r.received, r.name, r.status, r.result, r.score, r.tester, r.test_script, r.issued';
|
|
195
|
+
const rows = this.db.prepare(
|
|
196
|
+
`SELECT ${cols} FROM reports r ${where} ORDER BY ${search.orderBy} LIMIT ? OFFSET ?`
|
|
197
|
+
).all(...search.args, search.count, search.offset);
|
|
198
|
+
if (!options.withJson && rows.length > 0) {
|
|
199
|
+
const byId = new Map(rows.map(r => [r.id, r]));
|
|
200
|
+
for (const r of rows) {
|
|
201
|
+
r.participants = [];
|
|
202
|
+
}
|
|
203
|
+
const ph = rows.map(() => '?').join(',');
|
|
204
|
+
const parts = this.db.prepare(
|
|
205
|
+
`SELECT report_id, type, uri, version, display FROM participants WHERE report_id IN (${ph}) ORDER BY report_id, seq`
|
|
206
|
+
).all(...rows.map(r => r.id));
|
|
207
|
+
for (const p of parts) {
|
|
208
|
+
byId.get(p.report_id).participants.push(p);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return { total, rows };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** The distinct values in a column, for filter drop-downs. */
|
|
215
|
+
distinct(column) {
|
|
216
|
+
if (!['status', 'result'].includes(column)) {
|
|
217
|
+
throw new Error('Not a filterable column: ' + column);
|
|
218
|
+
}
|
|
219
|
+
return this.db.prepare(`SELECT DISTINCT ${column} AS v FROM reports ORDER BY ${column}`).all().map(r => r.v);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* How many reports each tester has sent, with how they came out, and when the latest
|
|
224
|
+
* was received. Most reports first.
|
|
225
|
+
*
|
|
226
|
+
* @returns {Array<{tester: string, reports: number, pass: number, fail: number, other: number,
|
|
227
|
+
* latest: string}>}
|
|
228
|
+
*/
|
|
229
|
+
testerCounts() {
|
|
230
|
+
return this.db.prepare(`
|
|
231
|
+
SELECT tester, COUNT(*) AS reports,
|
|
232
|
+
SUM(CASE WHEN result = 'pass' THEN 1 ELSE 0 END) AS pass,
|
|
233
|
+
SUM(CASE WHEN result = 'fail' THEN 1 ELSE 0 END) AS fail,
|
|
234
|
+
SUM(CASE WHEN result NOT IN ('pass', 'fail') THEN 1 ELSE 0 END) AS other,
|
|
235
|
+
MAX(received) AS latest
|
|
236
|
+
FROM reports GROUP BY tester ORDER BY reports DESC, tester_lc`).all();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* The latest report for each test script against each participant (or tester): the
|
|
241
|
+
* one with the latest issued date, then the latest received. Test engines are not
|
|
242
|
+
* participants in this sense - the columns are the things that were tested.
|
|
243
|
+
*
|
|
244
|
+
* @param {'participant'|'tester'} by
|
|
245
|
+
* @returns {Array<{test_script: string|null, col: string, id: string, name: string,
|
|
246
|
+
* result: string, score: number|null, issued: string, runs: number}>}
|
|
247
|
+
*/
|
|
248
|
+
summary(by) {
|
|
249
|
+
const source = by === 'tester'
|
|
250
|
+
? 'SELECT r.*, r.tester AS col FROM reports r'
|
|
251
|
+
: `SELECT r.*, p.uri AS col FROM reports r
|
|
252
|
+
JOIN (SELECT DISTINCT report_id, uri FROM participants
|
|
253
|
+
WHERE type IS NULL OR type <> 'test-engine') p ON p.report_id = r.id`;
|
|
254
|
+
return this.db.prepare(`
|
|
255
|
+
SELECT test_script, col, id, name, result, score, issued, runs FROM (
|
|
256
|
+
SELECT s.*,
|
|
257
|
+
COUNT(*) OVER (PARTITION BY s.test_script, s.col) AS runs,
|
|
258
|
+
ROW_NUMBER() OVER (PARTITION BY s.test_script, s.col
|
|
259
|
+
ORDER BY s.issued_lo DESC, s.received_ms DESC) AS rn
|
|
260
|
+
FROM (${source}) s
|
|
261
|
+
) WHERE rn = 1
|
|
262
|
+
ORDER BY test_script, col`).all();
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
module.exports = { TestReportStore, testScriptOf };
|