fhirsmith 0.13.4 → 0.14.1

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 (52) hide show
  1. package/CHANGELOG.md +61 -1
  2. package/README.md +16 -3
  3. package/config-template.json +81 -1
  4. package/library/cgroup-memory.js +113 -0
  5. package/library/folder-setup.js +8 -1
  6. package/library/languages.js +15 -0
  7. package/library/same-origin.js +62 -0
  8. package/nginx.md +399 -0
  9. package/package.json +3 -2
  10. package/publisher/git-remote.js +141 -0
  11. package/publisher/github-release.js +195 -0
  12. package/publisher/publisher-template.html +1 -0
  13. package/publisher/publisher.js +241 -56
  14. package/registry/registry.js +3 -1
  15. package/server.js +57 -15
  16. package/stats.js +19 -13
  17. package/testing/readme.md +144 -0
  18. package/testing/render.js +723 -0
  19. package/testing/search.js +378 -0
  20. package/testing/store.js +410 -0
  21. package/testing/testing-template.html +126 -0
  22. package/testing/testing.js +760 -0
  23. package/translations/Messages.properties +32 -0
  24. package/tx/closure/closure-store.js +396 -0
  25. package/tx/cs/cs-api.js +6 -1
  26. package/tx/cs/cs-rxnorm.js +35 -8
  27. package/tx/cs/cs-snomed.js +19 -69
  28. package/tx/html/codesystem-operations.liquid +177 -0
  29. package/tx/html/ecl-panel.liquid +93 -0
  30. package/tx/html/home-metrics.liquid +39 -76
  31. package/tx/html/tx-template.html +1 -0
  32. package/tx/library/conceptmap.js +22 -6
  33. package/tx/library/designations.js +12 -3
  34. package/tx/library/operation-outcome.js +3 -1
  35. package/tx/library/renderer.js +42 -5
  36. package/tx/operation-context.js +80 -17
  37. package/tx/provider.js +85 -0
  38. package/tx/sct/editions.js +70 -0
  39. package/tx/sct/expressions.js +128 -44
  40. package/tx/tx-html.js +426 -44
  41. package/tx/tx.js +117 -9
  42. package/tx/workers/cache-control.js +2 -2
  43. package/tx/workers/closure.js +483 -13
  44. package/tx/workers/expand.js +2 -2
  45. package/tx/workers/metadata.js +8 -3
  46. package/tx/workers/read.js +18 -3
  47. package/tx/workers/subsumes.js +18 -2
  48. package/tx/workers/translate.js +192 -21
  49. package/tx/workers/validate.js +70 -187
  50. package/tx/workers/worker.js +12 -35
  51. package/tx/xversion/xv-conceptmap.js +17 -8
  52. 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
+ };