fhirsmith 0.13.3 → 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.
Files changed (49) hide show
  1. package/CHANGELOG.md +47 -1
  2. package/README.md +48 -4
  3. package/config-template.json +70 -1
  4. package/library/cgroup-memory.js +113 -0
  5. package/library/folder-setup.js +8 -1
  6. package/nginx.md +391 -0
  7. package/package.json +3 -2
  8. package/publisher/git-remote.js +141 -0
  9. package/publisher/github-release.js +195 -0
  10. package/publisher/publisher.js +146 -14
  11. package/server.js +51 -14
  12. package/stats.js +19 -13
  13. package/testing/readme.md +112 -0
  14. package/testing/render.js +482 -0
  15. package/testing/search.js +378 -0
  16. package/testing/store.js +266 -0
  17. package/testing/testing-template.html +126 -0
  18. package/testing/testing.js +486 -0
  19. package/translations/Messages.properties +32 -0
  20. package/tx/closure/closure-store.js +396 -0
  21. package/tx/cs/cs-api.js +6 -1
  22. package/tx/cs/cs-rxnorm.js +35 -8
  23. package/tx/cs/cs-snomed.js +142 -27
  24. package/tx/html/codesystem-operations.liquid +177 -0
  25. package/tx/html/ecl-panel.liquid +93 -0
  26. package/tx/html/home-metrics.liquid +39 -76
  27. package/tx/html/tx-template.html +1 -0
  28. package/tx/library/conceptmap.js +22 -6
  29. package/tx/library/designations.js +12 -3
  30. package/tx/library/extensions.js +2 -2
  31. package/tx/library/operation-outcome.js +3 -1
  32. package/tx/library/renderer.js +42 -5
  33. package/tx/operation-context.js +111 -15
  34. package/tx/provider.js +85 -0
  35. package/tx/sct/expressions.js +128 -44
  36. package/tx/sct/text-index.js +419 -0
  37. package/tx/tx-html.js +426 -44
  38. package/tx/tx.js +117 -9
  39. package/tx/workers/cache-control.js +2 -2
  40. package/tx/workers/closure.js +483 -13
  41. package/tx/workers/expand.js +20 -0
  42. package/tx/workers/metadata.js +29 -4
  43. package/tx/workers/read.js +18 -3
  44. package/tx/workers/subsumes.js +18 -2
  45. package/tx/workers/translate.js +167 -20
  46. package/tx/workers/validate.js +61 -182
  47. package/tx/workers/worker.js +9 -0
  48. package/tx/xversion/xv-conceptmap.js +17 -8
  49. 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
+ };
@@ -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 };