fhirsmith 0.17.0 → 0.18.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 (131) hide show
  1. package/CHANGELOG.md +113 -0
  2. package/README.md +80 -13
  3. package/config-template.json +106 -1
  4. package/ips/ai-client.js +263 -0
  5. package/ips/composer.js +805 -0
  6. package/ips/editor-schema.js +289 -0
  7. package/ips/editor.js +290 -0
  8. package/ips/fhirpath-ips.js +397 -0
  9. package/ips/ips-codeable-concepts.md +1742 -0
  10. package/ips/ips-template.html +125 -0
  11. package/ips/ips-valuesets.js +159 -0
  12. package/ips/ips.js +1736 -0
  13. package/ips/membership.js +179 -0
  14. package/ips/pipeline.js +1012 -0
  15. package/ips/presenter.js +974 -0
  16. package/ips/profile-editor.js +578 -0
  17. package/ips/profiles.js +506 -0
  18. package/ips/readme.md +460 -0
  19. package/ips/render.js +1482 -0
  20. package/ips/search-params.js +83 -0
  21. package/ips/search.js +667 -0
  22. package/ips/share.js +178 -0
  23. package/ips/store.js +650 -0
  24. package/ips/terminology.js +395 -0
  25. package/ips/tx-client.js +202 -0
  26. package/ips/valuesets/CodeSystem-absent-unknown-uv-ips.json +206 -0
  27. package/ips/valuesets/ValueSet-absent-or-unknown-immunizations-uv-ips.json +59 -0
  28. package/ips/valuesets/ValueSet-ips-allergy-code.json +59 -0
  29. package/ips/valuesets/ValueSet-ips-allergy-manifestation.json +356 -0
  30. package/ips/valuesets/ValueSet-ips-allergy-substance.json +37 -0
  31. package/ips/valuesets/ValueSet-ips-body-site.json +26 -0
  32. package/ips/valuesets/ValueSet-ips-clinical-finding-or-none.json +35 -0
  33. package/ips/valuesets/ValueSet-ips-clinical-finding.json +26 -0
  34. package/ips/valuesets/ValueSet-ips-condition-code.json +72 -0
  35. package/ips/valuesets/ValueSet-ips-condition-severity.json +30 -0
  36. package/ips/valuesets/ValueSet-ips-condition-stage.json +26 -0
  37. package/ips/valuesets/ValueSet-ips-exposure-route.json +26 -0
  38. package/ips/valuesets/ValueSet-ips-imaging-procedure.json +25 -0
  39. package/ips/valuesets/ValueSet-ips-immunization-status-reason.json +43 -0
  40. package/ips/valuesets/ValueSet-ips-international-codes.json +25 -0
  41. package/ips/valuesets/ValueSet-ips-medication-ingredient.json +26 -0
  42. package/ips/valuesets/ValueSet-ips-medication-status-reason.json +37 -0
  43. package/ips/valuesets/ValueSet-ips-medication.json +50 -0
  44. package/ips/valuesets/ValueSet-ips-observation-code.json +18 -0
  45. package/ips/valuesets/ValueSet-ips-observation-interpretation.json +18 -0
  46. package/ips/valuesets/ValueSet-ips-procedure-followup.json +51 -0
  47. package/ips/valuesets/ValueSet-ips-procedure-outcome.json +30 -0
  48. package/ips/valuesets/ValueSet-ips-procedure-reason.json +37 -0
  49. package/ips/valuesets/ValueSet-ips-procedure.json +116 -0
  50. package/ips/valuesets/ValueSet-ips-route.json +46 -0
  51. package/ips/valuesets/ValueSet-ips-target-disease.json +171 -0
  52. package/ips/valuesets/ValueSet-ips-vaccine.json +31 -0
  53. package/ips/valuesets/elements.json +44 -0
  54. package/library/daily-report.js +432 -0
  55. package/library/fhir-search-values.js +138 -0
  56. package/library/html-server.js +32 -23
  57. package/library/mailer.js +77 -0
  58. package/library/narrative-check.js +21 -2
  59. package/library/npm-audit.js +25 -0
  60. package/library/package-manager.js +4 -2
  61. package/library/sqlite-session-store.js +29 -1
  62. package/package.json +8 -3
  63. package/packages/packages.js +34 -3
  64. package/publisher/publisher.js +29 -0
  65. package/registry/auth-store.js +366 -0
  66. package/registry/auth.js +872 -0
  67. package/registry/crawler.js +41 -1
  68. package/registry/documentation/api.md +162 -0
  69. package/registry/documentation/configuration.md +93 -0
  70. package/registry/documentation/documents.md +74 -0
  71. package/registry/documentation/internals.md +151 -0
  72. package/registry/documentation/readme.md +100 -0
  73. package/registry/documentation/security.md +199 -0
  74. package/registry/documents.js +309 -0
  75. package/registry/openapi.yaml +30 -0
  76. package/registry/registry-template.html +1 -1
  77. package/registry/registry.js +230 -44
  78. package/registry/uptime.js +154 -0
  79. package/server.js +105 -44
  80. package/shl/shl.js +64 -9
  81. package/stats-db.js +93 -0
  82. package/stats.js +158 -1
  83. package/testing/search.js +2 -108
  84. package/testing/store.js +5 -0
  85. package/testing/testing.js +20 -0
  86. package/token/token-template.html +5 -4
  87. package/token/token.js +17 -10
  88. package/translations/Messages.properties +10 -0
  89. package/tx/README.md +52 -0
  90. package/tx/closure/closure-store.js +14 -0
  91. package/tx/cs/cs-api.js +92 -14
  92. package/tx/cs/cs-cs.js +64 -3
  93. package/tx/cs/cs-icd11.js +366 -112
  94. package/tx/cs/cs-icf.js +431 -0
  95. package/tx/cs/cs-loinc.js +296 -27
  96. package/tx/cs/cs-omop.js +10 -3
  97. package/tx/cs/cs-snomed.js +1210 -80
  98. package/tx/cs/cs-ucum.js +6 -0
  99. package/tx/cs/icd11-expressions.js +191 -78
  100. package/tx/importers/csv-records.js +35 -0
  101. package/tx/importers/export-icd11-codesystem.js +58 -12
  102. package/tx/importers/icd11-schema.md +9 -5
  103. package/tx/importers/import-loinc.module.js +177 -41
  104. package/tx/importers/import-sct.module.js +66 -1
  105. package/tx/importers/subset-loinc.module.js +25 -6
  106. package/tx/library/codesystem.js +4 -1
  107. package/tx/library/designations.js +23 -30
  108. package/tx/library.js +98 -4
  109. package/tx/openapi-operations.js +6 -1
  110. package/tx/params.js +8 -1
  111. package/tx/provider.js +24 -6
  112. package/tx/sct/documentation.md +251 -0
  113. package/tx/sct/ecl.js +758 -46
  114. package/tx/sct/expressions.js +113 -52
  115. package/tx/sct/structures.js +8 -2
  116. package/tx/syndication/syndication-feed.js +218 -0
  117. package/tx/syndication/syndication-store.js +415 -0
  118. package/tx/tx-html.js +15 -1
  119. package/tx/tx.fhir.org.yml +3 -4
  120. package/tx/tx.js +144 -2
  121. package/tx/vs/vs-vsac.js +25 -6
  122. package/tx/workers/closure.js +20 -0
  123. package/tx/workers/expand.js +35 -8
  124. package/tx/workers/lookup.js +61 -6
  125. package/tx/workers/subsumes.js +1 -1
  126. package/tx/workers/translate.js +166 -15
  127. package/tx/workers/validate.js +138 -6
  128. package/utilities/make-sct-loinc-subset.js +250 -0
  129. package/utilities/make-sct-test-extension.js +264 -0
  130. package/xig/xig.js +32 -0
  131. package/registry/readme.md +0 -124
package/ips/search.js ADDED
@@ -0,0 +1,667 @@
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
+ * Indexing and searching IPS documents.
9
+ *
10
+ * Indexing: when a document arrives, every search parameter in search-params.js is
11
+ * evaluated (with fhirpath.js) against the Bundle, its Composition (the first entry) and
12
+ * its Patient (the Composition's subject), and the values go into one index table as
13
+ * rows of (resource, parameter, value). The value columns depend on the type:
14
+ *
15
+ * string str (as written) and str_lc (lower case); a HumanName or Address is
16
+ * split into its parts, so "name" matches any part
17
+ * token sys and code; an Identifier's value and a ContactPoint's value are the code
18
+ * date lo and hi, the [lo, hi) range in epoch ms; a Period is one row
19
+ * reference ref, the reference string as written
20
+ *
21
+ * Searching: a query on Bundle, with the Composition and Patient parameters reached by
22
+ * chaining - composition.date, composition.patient.name, composition.subject.birthdate -
23
+ * becomes SQL over the documents table (alias d) with an EXISTS against the index for
24
+ * each parameter. Comma separated values are ORed, repeated parameters are ANDed, and
25
+ * the usual modifiers apply: :exact and :contains for strings, :not for tokens, :missing
26
+ * for anything. Bundle has _id and _lastUpdated as well; processing-status is this
27
+ * module's own parameter (the document's place in the processing queue).
28
+ *
29
+ * @module ips/search
30
+ */
31
+
32
+ const fhirpath = require('fhirpath');
33
+ const r4Model = require('fhirpath/fhir-context/r4');
34
+ const { dateRange, splitPrefix, escapeLike, splitValues, dateClause } = require('../library/fhir-search-values');
35
+ const { BY_RESOURCE, CHAINS } = require('./search-params');
36
+
37
+ const DEFAULT_COUNT = 50;
38
+ const MAX_COUNT = 500;
39
+ const MAX_STRING = 1024;
40
+ // index rows kept for one parameter of one document: a Patient with ten thousand names is
41
+ // nobody's patient
42
+ const MAX_VALUES_PER_PARAM = 200;
43
+ // sort keys in one search
44
+ const MAX_SORT_KEYS = 5;
45
+
46
+ // the processing states a document goes through (store.js moves them along)
47
+ const STATUSES = ['submitted', 'processing', 'processed', 'error'];
48
+
49
+ // parameters that control the search rather than filter it
50
+ const CONTROL = new Set(['_sort', '_count', '_offset', '_summary', '_format', '_total', '_pretty']);
51
+
52
+ const MODIFIERS = {
53
+ string: ['exact', 'contains', 'missing'],
54
+ token: ['not', 'missing'],
55
+ date: ['missing'],
56
+ reference: ['missing'],
57
+ id: [],
58
+ status: ['not']
59
+ };
60
+
61
+ // one end of a Period that is left open
62
+ const MIN_MS = -8640000000000000;
63
+ const MAX_MS = 8640000000000000;
64
+
65
+ // ---- the document's parts ------------------------------------------------------------
66
+
67
+ function isObject(v) {
68
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
69
+ }
70
+
71
+ /** The Composition: the first entry's resource, if it is one. */
72
+ function compositionOf(bundle) {
73
+ const e = Array.isArray(bundle?.entry) ? bundle.entry[0] : null;
74
+ const r = e?.resource;
75
+ return isObject(r) && r.resourceType === 'Composition' ? r : null;
76
+ }
77
+
78
+ // the lookup tables for a bundle's entries, built once per bundle object: resolving every
79
+ // entry reference by scanning every entry is quadratic, and a document is the submitter's
80
+ const bundleIndexes = new WeakMap();
81
+
82
+ /**
83
+ * The lookup tables for a bundle: entries by fullUrl, by Type/id, and fullUrl by resource.
84
+ * @returns {{byFullUrl: Map, byTypeId: Map, fullUrlOf: Map}}
85
+ */
86
+ function bundleIndex(bundle) {
87
+ if (!isObject(bundle)) {
88
+ return { byFullUrl: new Map(), byTypeId: new Map(), fullUrlOf: new Map() };
89
+ }
90
+ let idx = bundleIndexes.get(bundle);
91
+ if (!idx) {
92
+ idx = { byFullUrl: new Map(), byTypeId: new Map(), fullUrlOf: new Map() };
93
+ for (const e of Array.isArray(bundle.entry) ? bundle.entry : []) {
94
+ const r = e?.resource;
95
+ if (!isObject(e) || !isObject(r)) {
96
+ continue;
97
+ }
98
+ if (typeof e.fullUrl === 'string') {
99
+ if (!idx.byFullUrl.has(e.fullUrl)) {
100
+ idx.byFullUrl.set(e.fullUrl, r);
101
+ }
102
+ idx.fullUrlOf.set(r, e.fullUrl);
103
+ }
104
+ if (typeof r.resourceType === 'string' && typeof r.id === 'string') {
105
+ const key = `${r.resourceType}/${r.id}`;
106
+ if (!idx.byTypeId.has(key)) {
107
+ idx.byTypeId.set(key, r);
108
+ }
109
+ }
110
+ }
111
+ bundleIndexes.set(bundle, idx);
112
+ }
113
+ return idx;
114
+ }
115
+
116
+ /**
117
+ * The resource an entry reference points to, within the bundle: by fullUrl, or by
118
+ * [Type/]id at the end of the reference.
119
+ */
120
+ function resolveInBundle(bundle, reference) {
121
+ if (typeof reference !== 'string' || !Array.isArray(bundle?.entry)) {
122
+ return null;
123
+ }
124
+ const idx = bundleIndex(bundle);
125
+ const direct = idx.byFullUrl.get(reference);
126
+ if (direct) {
127
+ return direct;
128
+ }
129
+ const m = /(?:^|\/)([A-Za-z]+)\/([A-Za-z0-9.-]{1,64})(?:\/_history\/[^/]+)?$/.exec(reference);
130
+ return m ? idx.byTypeId.get(`${m[1]}/${m[2]}`) || null : null;
131
+ }
132
+
133
+ /**
134
+ * The Patient: the Composition's subject, resolved within the bundle; failing that, the
135
+ * first Patient in the bundle.
136
+ */
137
+ function patientOf(bundle, composition = compositionOf(bundle)) {
138
+ const ref = composition?.subject?.reference;
139
+ const r = resolveInBundle(bundle, ref);
140
+ if (r && r.resourceType === 'Patient') {
141
+ return r;
142
+ }
143
+ for (const e of Array.isArray(bundle?.entry) ? bundle.entry : []) {
144
+ if (isObject(e?.resource) && e.resource.resourceType === 'Patient') {
145
+ return e.resource;
146
+ }
147
+ }
148
+ return null;
149
+ }
150
+
151
+ // ---- indexing ------------------------------------------------------------------------
152
+
153
+ function stringParts(v) {
154
+ if (typeof v === 'string') {
155
+ return [v];
156
+ }
157
+ if (typeof v === 'number' || typeof v === 'boolean') {
158
+ return [String(v)];
159
+ }
160
+ if (!isObject(v)) {
161
+ return [];
162
+ }
163
+ const out = [];
164
+ const add = (x) => {
165
+ if (typeof x === 'string' && x.trim() !== '') {
166
+ out.push(x);
167
+ }
168
+ };
169
+ // HumanName
170
+ add(v.text);
171
+ add(v.family);
172
+ (Array.isArray(v.given) ? v.given : []).forEach(add);
173
+ (Array.isArray(v.prefix) ? v.prefix : []).forEach(add);
174
+ (Array.isArray(v.suffix) ? v.suffix : []).forEach(add);
175
+ // Address
176
+ (Array.isArray(v.line) ? v.line : []).forEach(add);
177
+ add(v.city);
178
+ add(v.district);
179
+ add(v.state);
180
+ add(v.postalCode);
181
+ add(v.country);
182
+ return out;
183
+ }
184
+
185
+ function tokenParts(v) {
186
+ if (typeof v === 'boolean' || typeof v === 'number') {
187
+ return [{ sys: null, code: String(v) }];
188
+ }
189
+ if (typeof v === 'string') {
190
+ return v === '' ? [] : [{ sys: null, code: v }];
191
+ }
192
+ if (!isObject(v)) {
193
+ return [];
194
+ }
195
+ if (Array.isArray(v.coding)) {
196
+ // CodeableConcept
197
+ return v.coding.flatMap(tokenParts);
198
+ }
199
+ const sys = typeof v.system === 'string' ? v.system : null;
200
+ // Coding has code; Identifier and ContactPoint have value
201
+ const code = typeof v.code === 'string' ? v.code : (typeof v.value === 'string' ? v.value : null);
202
+ return code ? [{ sys, code }] : [];
203
+ }
204
+
205
+ function dateParts(v) {
206
+ if (typeof v === 'string') {
207
+ const r = dateRange(v);
208
+ return r ? [r] : [];
209
+ }
210
+ if (isObject(v) && (typeof v.start === 'string' || typeof v.end === 'string')) {
211
+ // Period
212
+ const s = typeof v.start === 'string' ? dateRange(v.start) : null;
213
+ const e = typeof v.end === 'string' ? dateRange(v.end) : null;
214
+ if (!s && !e) {
215
+ return [];
216
+ }
217
+ return [{ lo: s ? s.lo : MIN_MS, hi: e ? e.hi : MAX_MS }];
218
+ }
219
+ return [];
220
+ }
221
+
222
+ function referenceParts(v) {
223
+ if (typeof v === 'string') {
224
+ return v === '' ? [] : [v];
225
+ }
226
+ if (isObject(v) && typeof v.reference === 'string' && v.reference !== '') {
227
+ return [v.reference];
228
+ }
229
+ return [];
230
+ }
231
+
232
+ function evaluate(resource, expression) {
233
+ try {
234
+ return fhirpath.evaluate(resource, expression, null, r4Model);
235
+ } catch {
236
+ // an expression that fails on this resource indexes nothing; the document is still stored
237
+ return [];
238
+ }
239
+ }
240
+
241
+ /**
242
+ * Index rows for one resource against one table of parameters.
243
+ * @returns {Array<{res, param, type, str, str_lc, sys, code, lo, hi, ref}>}
244
+ */
245
+ function indexResource(res, resource, params) {
246
+ const rows = [];
247
+ const row = (param, type, v) => rows.push({
248
+ res, param, type,
249
+ str: null, str_lc: null, sys: null, code: null, lo: null, hi: null, ref: null, ...v
250
+ });
251
+ for (const [param, def] of Object.entries(params)) {
252
+ const values = evaluate(resource, def.expression).slice(0, MAX_VALUES_PER_PARAM);
253
+ const before = rows.length;
254
+ for (const v of values) {
255
+ if (rows.length - before >= MAX_VALUES_PER_PARAM) {
256
+ break;
257
+ }
258
+ switch (def.type) {
259
+ case 'string':
260
+ for (const s of stringParts(v)) {
261
+ const t = s.substring(0, MAX_STRING);
262
+ row(param, 'string', { str: t, str_lc: t.toLowerCase() });
263
+ }
264
+ break;
265
+ case 'token':
266
+ for (const t of tokenParts(v)) {
267
+ row(param, 'token', { sys: t.sys, code: t.code.substring(0, MAX_STRING) });
268
+ }
269
+ break;
270
+ case 'date':
271
+ for (const d of dateParts(v)) {
272
+ row(param, 'date', { lo: d.lo, hi: d.hi });
273
+ }
274
+ break;
275
+ case 'reference':
276
+ for (const r of referenceParts(v)) {
277
+ row(param, 'reference', { ref: r.substring(0, MAX_STRING) });
278
+ }
279
+ break;
280
+ }
281
+ }
282
+ }
283
+ return rows;
284
+ }
285
+
286
+ /**
287
+ * All the index rows for a document.
288
+ *
289
+ * @param {Object} bundle - the document, as it will be stored (with its server id)
290
+ * @returns {Array} rows for the index table (without the doc_id)
291
+ */
292
+ function indexDocument(bundle) {
293
+ const rows = [];
294
+ // Bundle.composition: the expression yields the resource itself, so it is done by hand
295
+ const { composition: compDef, ...bundleParams } = BY_RESOURCE.Bundle;
296
+ rows.push(...indexResource('Bundle', bundle, bundleParams));
297
+ const comp = compositionOf(bundle);
298
+ if (comp) {
299
+ const refs = [];
300
+ if (typeof bundle.entry[0].fullUrl === 'string') {
301
+ refs.push(bundle.entry[0].fullUrl);
302
+ }
303
+ if (typeof comp.id === 'string') {
304
+ refs.push('Composition/' + comp.id);
305
+ }
306
+ for (const ref of refs) {
307
+ rows.push({ res: 'Bundle', param: 'composition', type: compDef.type, str: null, str_lc: null, sys: null, code: null, lo: null, hi: null, ref });
308
+ }
309
+ rows.push(...indexResource('Composition', comp, BY_RESOURCE.Composition));
310
+ const patient = patientOf(bundle, comp);
311
+ if (patient) {
312
+ rows.push(...indexResource('Patient', patient, BY_RESOURCE.Patient));
313
+ }
314
+ }
315
+ return rows;
316
+ }
317
+
318
+ // ---- searching -----------------------------------------------------------------------
319
+
320
+ /**
321
+ * Work out which resource and parameter a (possibly chained) parameter name refers to.
322
+ * composition.patient.name -> Patient / name. A :Type on a chain link is ignored.
323
+ *
324
+ * @returns {{res: string, param: string, def: Object}|null}
325
+ */
326
+ function resolveParam(name) {
327
+ const parts = name.split('.').map(p => p.replace(/:.*$/, ''));
328
+ let res = 'Bundle';
329
+ for (let i = 0; i < parts.length - 1; i++) {
330
+ const p = parts[i];
331
+ if (res === 'Bundle') {
332
+ const chain = Object.prototype.hasOwnProperty.call(CHAINS, p) ? CHAINS[p] : null;
333
+ if (!chain) {
334
+ return null;
335
+ }
336
+ res = chain.resource;
337
+ } else {
338
+ const chain = CHAINS.composition;
339
+ if (res !== chain.resource || !Object.prototype.hasOwnProperty.call(chain.via, p)) {
340
+ return null;
341
+ }
342
+ res = chain.via[p];
343
+ }
344
+ }
345
+ const param = parts[parts.length - 1];
346
+ const table = BY_RESOURCE[res];
347
+ if (!Object.prototype.hasOwnProperty.call(table, param)) {
348
+ return null;
349
+ }
350
+ return { res, param, def: table[param] };
351
+ }
352
+
353
+ /** The condition on one index row for one value. null if the value is not valid. */
354
+ function valueCondition(def, modifier, value) {
355
+ switch (def.type) {
356
+ case 'string': {
357
+ if (modifier === 'exact') {
358
+ return { sql: 'i.str = ?', args: [value] };
359
+ }
360
+ const v = escapeLike(value.toLowerCase());
361
+ return { sql: "i.str_lc LIKE ? ESCAPE '\\'", args: [modifier === 'contains' ? `%${v}%` : `${v}%`] };
362
+ }
363
+ case 'token': {
364
+ const bar = value.indexOf('|');
365
+ if (bar < 0) {
366
+ return { sql: 'i.code = ?', args: [value] };
367
+ }
368
+ const sys = value.substring(0, bar);
369
+ const code = value.substring(bar + 1);
370
+ if (code === '') {
371
+ return { sql: 'i.sys = ?', args: [sys] };
372
+ }
373
+ if (sys === '') {
374
+ return { sql: '(i.sys IS NULL AND i.code = ?)', args: [code] };
375
+ }
376
+ return { sql: '(i.sys = ? AND i.code = ?)', args: [sys, code] };
377
+ }
378
+ case 'date': {
379
+ const { prefix, rest } = splitPrefix(value);
380
+ const range = dateRange(rest);
381
+ return range ? dateClause({ lo: 'i.lo', hi: 'i.hi' }, prefix, range) : null;
382
+ }
383
+ case 'reference': {
384
+ // as written, or Type/id (or just id) at the end of what was written
385
+ const tail = escapeLike(value);
386
+ return { sql: "(i.ref = ? OR i.ref LIKE ? ESCAPE '\\')", args: [value, `%/${tail}`] };
387
+ }
388
+ }
389
+ return null;
390
+ }
391
+
392
+ function asList(v) {
393
+ if (v === undefined || v === null) {
394
+ return [];
395
+ }
396
+ return (Array.isArray(v) ? v : [v]).filter(x => typeof x === 'string');
397
+ }
398
+
399
+ function intParam(v, dflt) {
400
+ const s = asList(v)[0];
401
+ if (s === undefined || s === '') {
402
+ return dflt;
403
+ }
404
+ // a whole number SQLite can take (a bigger one is an error from it, not an answer)
405
+ if (!/^\d{1,15}$/.test(s)) {
406
+ return NaN;
407
+ }
408
+ return parseInt(s, 10);
409
+ }
410
+
411
+ // the index rows of one parameter, for a document; the arguments are (res, param)
412
+ const INDEXED = 'SELECT 1 FROM ips_index i WHERE i.doc_id = d.id AND i.res = ? AND i.param = ?';
413
+
414
+ /**
415
+ * Parse a search over one project's documents.
416
+ *
417
+ * @param {Object} query - parsed query string; values are strings or arrays of strings
418
+ * @returns {{
419
+ * where: string[], args: any[], orderBy: string, orderArgs: any[], count: number, offset: number,
420
+ * summaryCount: boolean, used: Array<[string,string]>, sort: string|null,
421
+ * unknown: string[], errors: string[]
422
+ * }} where/args don't include the project - the store adds that
423
+ */
424
+ function parseSearch(query) {
425
+ const result = {
426
+ where: [], args: [], orderBy: 'd.id DESC', orderArgs: [], count: DEFAULT_COUNT, offset: 0,
427
+ summaryCount: false, used: [], sort: null, unknown: [], errors: []
428
+ };
429
+ for (const [key, raw] of Object.entries(query || {})) {
430
+ if (CONTROL.has(key)) {
431
+ continue;
432
+ }
433
+ const colon = key.indexOf(':');
434
+ const name = colon < 0 ? key : key.substring(0, colon);
435
+ const modifier = colon < 0 ? null : key.substring(colon + 1);
436
+ const values = asList(raw).filter(v => v !== '');
437
+ if (values.length === 0) {
438
+ continue;
439
+ }
440
+
441
+ // the Bundle's own, non-indexed parameters
442
+ if (name === '_id' || name === '_lastUpdated' || name === 'processing-status') {
443
+ const type = name === '_id' ? 'id' : (name === '_lastUpdated' ? 'date' : 'status');
444
+ if (modifier !== null && !(type === 'status' && modifier === 'not') && !(type === 'date' && modifier === 'missing')) {
445
+ result.unknown.push(key);
446
+ continue;
447
+ }
448
+ for (const value of values) {
449
+ const clauses = [];
450
+ for (const v of splitValues(value)) {
451
+ if (type === 'id') {
452
+ clauses.push(/^\d{1,18}$/.test(v) ? { sql: 'd.id = ?', args: [parseInt(v, 10)] } : { sql: '0', args: [] });
453
+ } else if (type === 'status') {
454
+ if (!STATUSES.includes(v)) {
455
+ result.errors.push(`${key}: '${v}' is not a processing status (${STATUSES.join(', ')})`);
456
+ } else {
457
+ clauses.push({ sql: 'd.status = ?', args: [v] });
458
+ }
459
+ } else if (modifier === 'missing') {
460
+ clauses.push({ sql: v === 'true' ? '0' : '1', args: [] });
461
+ } else {
462
+ const { prefix, rest } = splitPrefix(v);
463
+ const range = dateRange(rest);
464
+ const c = range ? dateClause({ lo: 'd.received_ms', hi: '(d.received_ms + 1)' }, prefix, range) : null;
465
+ if (!c) {
466
+ result.errors.push(`${key}: '${v}' is not a valid date`);
467
+ } else {
468
+ clauses.push(c);
469
+ }
470
+ }
471
+ }
472
+ if (clauses.length > 0) {
473
+ const sql = clauses.length === 1 ? clauses[0].sql : '(' + clauses.map(c => c.sql).join(' OR ') + ')';
474
+ result.where.push(modifier === 'not' ? `NOT ${sql}` : sql);
475
+ clauses.forEach(c => result.args.push(...c.args));
476
+ result.used.push([key, value]);
477
+ }
478
+ }
479
+ continue;
480
+ }
481
+
482
+ const target = resolveParam(name);
483
+ if (!target) {
484
+ result.unknown.push(key);
485
+ continue;
486
+ }
487
+ const { res, param, def } = target;
488
+ if (modifier !== null && !MODIFIERS[def.type].includes(modifier)) {
489
+ result.unknown.push(key);
490
+ continue;
491
+ }
492
+ for (const value of values) {
493
+ if (modifier === 'missing') {
494
+ if (value !== 'true' && value !== 'false') {
495
+ result.errors.push(`${key}: '${value}' is not true or false`);
496
+ continue;
497
+ }
498
+ result.where.push(`${value === 'true' ? 'NOT ' : ''}EXISTS (${INDEXED})`);
499
+ result.args.push(res, param);
500
+ result.used.push([key, value]);
501
+ continue;
502
+ }
503
+ const conds = [];
504
+ for (const v of splitValues(value)) {
505
+ const c = valueCondition(def, modifier, v);
506
+ if (!c) {
507
+ result.errors.push(`${key}: '${v}' is not a valid ${def.type}`);
508
+ } else {
509
+ conds.push(c);
510
+ }
511
+ }
512
+ if (conds.length > 0) {
513
+ const inner = conds.length === 1 ? conds[0].sql : '(' + conds.map(c => c.sql).join(' OR ') + ')';
514
+ result.where.push(`${modifier === 'not' ? 'NOT ' : ''}EXISTS (${INDEXED} AND ${inner})`);
515
+ result.args.push(res, param);
516
+ conds.forEach(c => result.args.push(...c.args));
517
+ result.used.push([key, value]);
518
+ }
519
+ }
520
+ }
521
+
522
+ // _sort: _id, _lastUpdated, processing-status, or any indexed parameter (by its first value)
523
+ const sortSpec = asList(query && query._sort).join(',');
524
+ if (sortSpec) {
525
+ const parts = [];
526
+ const items = [...new Set(sortSpec.split(',').map(s => s.trim()).filter(Boolean))];
527
+ if (items.length > MAX_SORT_KEYS) {
528
+ result.errors.push(`_sort: at most ${MAX_SORT_KEYS} sort keys`);
529
+ items.length = 0;
530
+ }
531
+ for (const item of items) {
532
+ const desc = item.startsWith('-');
533
+ const name = desc ? item.substring(1) : item;
534
+ const dir = desc ? 'DESC' : 'ASC';
535
+ if (name === '_id' || name === '_lastUpdated') {
536
+ parts.push(`d.id ${dir}`);
537
+ continue;
538
+ }
539
+ if (name === 'processing-status') {
540
+ parts.push(`d.status ${dir}`);
541
+ continue;
542
+ }
543
+ const target = resolveParam(name);
544
+ if (!target) {
545
+ result.errors.push(`_sort: cannot sort on '${name}'`);
546
+ continue;
547
+ }
548
+ const col = { string: 'i.str_lc', token: 'i.code', date: 'i.lo', reference: 'i.ref' }[target.def.type];
549
+ parts.push(`(SELECT MIN(${col}) FROM ips_index i WHERE i.doc_id = d.id AND i.res = ? AND i.param = ?) ${dir}`);
550
+ result.orderArgs.push(target.res, target.param);
551
+ }
552
+ if (parts.length > 0) {
553
+ result.orderBy = parts.join(', ') + ', d.id DESC';
554
+ result.sort = sortSpec;
555
+ }
556
+ }
557
+
558
+ const count = intParam(query && query._count, DEFAULT_COUNT);
559
+ if (Number.isNaN(count)) {
560
+ result.errors.push('_count must be a non-negative integer');
561
+ } else {
562
+ result.count = Math.min(count, MAX_COUNT);
563
+ }
564
+ const offset = intParam(query && query._offset, 0);
565
+ if (Number.isNaN(offset)) {
566
+ result.errors.push('_offset must be a non-negative integer');
567
+ } else {
568
+ result.offset = offset;
569
+ }
570
+ result.summaryCount = asList(query && query._summary)[0] === 'count' || result.count === 0;
571
+ return result;
572
+ }
573
+
574
+ /**
575
+ * Turn the project page's filter form (name, gender, date, identifier, status) into a
576
+ * FHIR search query. Other keys pass through, so the page also accepts the API's
577
+ * parameters.
578
+ *
579
+ * name any part of the patient's name (contains)
580
+ * gender the patient's gender
581
+ * date the Composition's date (a day)
582
+ * identifier part of the document id, or of a patient identifier's value - not a
583
+ * FHIR parameter; parseSearch doesn't know it, so the store adds it
584
+ * status processing status
585
+ */
586
+ function listQueryToSearch(query) {
587
+ const out = {};
588
+ const add = (k, v) => {
589
+ if (out[k] === undefined) {
590
+ out[k] = v;
591
+ } else {
592
+ out[k] = [].concat(out[k], v);
593
+ }
594
+ };
595
+ const first = (v) => (Array.isArray(v) ? v[0] : v);
596
+ for (const [k, raw] of Object.entries(query || {})) {
597
+ const v = first(raw);
598
+ if (typeof v !== 'string') {
599
+ continue;
600
+ }
601
+ const t = v.trim();
602
+ switch (k) {
603
+ case 'name':
604
+ if (t) add('composition.patient.name:contains', t);
605
+ break;
606
+ case 'gender':
607
+ if (t) add('composition.patient.gender', t);
608
+ break;
609
+ case 'date':
610
+ if (t) add('composition.date', t);
611
+ break;
612
+ case 'status':
613
+ if (t) add('processing-status', t);
614
+ break;
615
+ case 'identifier':
616
+ // handled by the store (see identifierClause)
617
+ break;
618
+ default:
619
+ add(k, raw);
620
+ }
621
+ }
622
+ return out;
623
+ }
624
+
625
+ /**
626
+ * The web filter's identifier: part of the document's id, or of any patient identifier.
627
+ * @returns {{sql: string, args: any[]}|null}
628
+ */
629
+ function identifierClause(value) {
630
+ const t = typeof value === 'string' ? value.trim() : '';
631
+ if (!t) {
632
+ return null;
633
+ }
634
+ const like = `%${escapeLike(t)}%`;
635
+ return {
636
+ sql: "(CAST(d.id AS TEXT) LIKE ? ESCAPE '\\' OR EXISTS (SELECT 1 FROM ips_index i WHERE i.doc_id = d.id AND i.res = 'Patient' AND i.param = 'identifier' AND i.code LIKE ? ESCAPE '\\'))",
637
+ args: [like, like]
638
+ };
639
+ }
640
+
641
+ /**
642
+ * The search parameters, for the CapabilityStatement: the Bundle ones, plus the chains
643
+ * as documentation.
644
+ */
645
+ function capabilitySearchParams() {
646
+ const out = [
647
+ { name: '_id', type: 'token', documentation: 'The id this server gave the document' },
648
+ { name: '_lastUpdated', type: 'date', documentation: 'When the document was received by this server' },
649
+ { name: 'processing-status', type: 'token', documentation: `Where the document is in processing: ${STATUSES.join(' | ')}` }
650
+ ];
651
+ for (const [name, def] of Object.entries(BY_RESOURCE.Bundle)) {
652
+ out.push({ name, type: def.type, documentation: def.description });
653
+ }
654
+ const chained = [];
655
+ for (const [name, def] of Object.entries(BY_RESOURCE.Composition)) {
656
+ chained.push({ name: `composition.${name}`, type: def.type, documentation: def.description });
657
+ }
658
+ for (const [name, def] of Object.entries(BY_RESOURCE.Patient)) {
659
+ chained.push({ name: `composition.patient.${name}`, type: def.type, documentation: def.description });
660
+ }
661
+ return out.concat(chained);
662
+ }
663
+
664
+ module.exports = {
665
+ indexDocument, parseSearch, listQueryToSearch, identifierClause, capabilitySearchParams, resolveParam,
666
+ compositionOf, patientOf, resolveInBundle, bundleIndex, STATUSES, DEFAULT_COUNT, MAX_COUNT, MAX_VALUES_PER_PARAM
667
+ };