fhirsmith 0.18.1 → 1.0.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 (214) hide show
  1. package/ACCESSIBILITY.md +119 -0
  2. package/CHANGELOG.md +190 -9
  3. package/CONFIGURATION.md +305 -0
  4. package/LICENSE +29 -0
  5. package/README.md +29 -122
  6. package/cluster.js +1220 -0
  7. package/cluster.md +707 -0
  8. package/config-template.json +77 -8
  9. package/extension-tracker/extension-tracker-template.html +5 -8
  10. package/extension-tracker/extension-tracker.js +0 -2
  11. package/folder/folder.js +7 -6
  12. package/ips/analysis.js +560 -0
  13. package/ips/ips-template.html +5 -8
  14. package/ips/ips.js +331 -33
  15. package/ips/jurisdictions.js +65 -0
  16. package/ips/nationality.js +120 -0
  17. package/ips/pipeline.js +131 -17
  18. package/ips/presenter.js +1 -1
  19. package/ips/profile-editor.js +283 -4
  20. package/ips/readme.md +170 -20
  21. package/ips/render.js +769 -109
  22. package/ips/scripts.js +46 -0
  23. package/ips/store.js +123 -11
  24. package/ips/structure.js +247 -0
  25. package/ips/terminology.js +1 -1
  26. package/library/body-limit.js +22 -0
  27. package/library/cache-generation.js +15 -0
  28. package/library/cgroup-memory.js +133 -1
  29. package/library/cluster-counts.js +121 -0
  30. package/library/cluster-events.js +153 -0
  31. package/library/cluster-metrics.js +193 -0
  32. package/library/cluster-status.js +468 -0
  33. package/library/config-reader.js +96 -0
  34. package/library/daily-report.js +100 -2
  35. package/library/html-server.js +27 -1
  36. package/library/i18nsupport.js +23 -1
  37. package/library/keep-alive.js +18 -0
  38. package/library/languages.js +17 -3
  39. package/library/logger.js +31 -0
  40. package/library/memory-guard.js +428 -0
  41. package/library/native-memory.js +52 -0
  42. package/library/npm-audit.js +51 -25
  43. package/library/openapi-doc.js +7 -5
  44. package/library/package-manager.js +8 -0
  45. package/library/role.js +197 -0
  46. package/library/server-credentials.js +169 -0
  47. package/library/sqlite-utilities.js +59 -0
  48. package/library/ssrf-guard.js +100 -0
  49. package/library/static-response-stats.js +16 -0
  50. package/library/version-utilities.js +50 -0
  51. package/library/worker-count.js +192 -0
  52. package/nginx.md +30 -11
  53. package/npmprojector/indexer.js +18 -10
  54. package/package.json +11 -8
  55. package/packages/feed-check-page.js +117 -0
  56. package/packages/feed-check.js +279 -0
  57. package/packages/npm-check.js +57 -3
  58. package/packages/npm-signing.js +219 -0
  59. package/packages/openapi.yaml +207 -1
  60. package/packages/package-crawler.js +322 -41
  61. package/packages/packages-template.html +6 -8
  62. package/packages/packages.js +604 -57
  63. package/packages/readme.md +113 -1
  64. package/publisher/publisher-template.html +2 -5
  65. package/publisher/publisher.js +121 -110
  66. package/publisher/readme.md +75 -0
  67. package/registry/api.js +179 -1
  68. package/registry/crawler.js +86 -108
  69. package/registry/documentation/api.md +68 -2
  70. package/registry/documentation/configuration.md +34 -0
  71. package/registry/documentation/internals.md +4 -1
  72. package/registry/documentation/readme.md +3 -2
  73. package/registry/model.js +8 -0
  74. package/registry/openapi.yaml +84 -0
  75. package/registry/registry-template.html +5 -8
  76. package/registry/registry.js +58 -6
  77. package/root-bare-template.html +7 -4
  78. package/root-template.html +5 -8
  79. package/server.js +398 -198
  80. package/shl/shl.js +38 -37
  81. package/static/a11y/a11y-check.js +832 -0
  82. package/static/a11y/axe.min.js +12 -0
  83. package/static/assets/css/bootstrap-fhir.css +15 -15
  84. package/static/assets/images/flags/au.svg +18 -0
  85. package/static/assets/images/flags/unknown.svg +11 -0
  86. package/static/assets/images/flags/us.svg +63 -0
  87. package/static/assets/images/icon_audit.png +0 -0
  88. package/static/assets/images/icon_clinical.png +0 -0
  89. package/static/assets/images/icon_document.png +0 -0
  90. package/static/assets/images/icon_external.png +0 -0
  91. package/static/assets/images/icon_medication.png +0 -0
  92. package/static/assets/images/icon_organization.png +0 -0
  93. package/static/assets/images/icon_other.png +0 -0
  94. package/static/assets/images/icon_person.png +0 -0
  95. package/static/assets/images/icon_section.png +0 -0
  96. package/stats-db.js +45 -0
  97. package/stats.js +197 -10
  98. package/testing/testing-template.html +5 -8
  99. package/token/nginx_passport_setup.md +1 -1
  100. package/token/security_guide.md +1 -1
  101. package/token/token-template.html +2 -5
  102. package/token/token.js +40 -36
  103. package/translations/Messages.properties +19 -0
  104. package/tx/README.md +158 -4
  105. package/tx/closure/closure-store.js +126 -3
  106. package/tx/cm/cm-database.js +69 -57
  107. package/tx/cm/cm-package.js +50 -16
  108. package/tx/cs/cs-api.js +284 -19
  109. package/tx/cs/cs-api.md +41 -4
  110. package/tx/cs/cs-areacode.js +1 -1
  111. package/tx/cs/cs-country.js +17 -5
  112. package/tx/cs/cs-cpt.js +33 -16
  113. package/tx/cs/cs-cs.js +199 -24
  114. package/tx/cs/cs-currency.js +1 -1
  115. package/tx/cs/cs-icd11.js +21 -13
  116. package/tx/cs/cs-icf.js +22 -3
  117. package/tx/cs/cs-lang.js +2 -2
  118. package/tx/cs/cs-loinc.js +193 -67
  119. package/tx/cs/cs-mimetypes.js +12 -2
  120. package/tx/cs/cs-ndc.js +40 -25
  121. package/tx/cs/cs-omop.js +54 -29
  122. package/tx/cs/cs-rxnorm.js +300 -196
  123. package/tx/cs/cs-snomed.js +861 -95
  124. package/tx/cs/cs-ucum.js +14 -4
  125. package/tx/cs/cs-unii.js +18 -14
  126. package/tx/cs/cs-virtual.js +210 -0
  127. package/tx/ecosystem-resolver.js +413 -0
  128. package/tx/html/cluster-dash-metrics.liquid +26 -0
  129. package/tx/html/cluster-home-metrics.liquid +31 -0
  130. package/tx/html/cluster-metrics-script.liquid +251 -0
  131. package/tx/html/codesystem-operations.liquid +3 -3
  132. package/tx/html/ecl-panel.liquid +1 -1
  133. package/tx/html/external-metrics.liquid +17 -0
  134. package/tx/html/operations-form.liquid +17 -17
  135. package/tx/html/search-form.liquid +11 -11
  136. package/tx/html/status-line.liquid +58 -0
  137. package/tx/html/status-panel.liquid +140 -0
  138. package/tx/html/status-tasks.liquid +25 -0
  139. package/tx/html/tx-template.html +2 -5
  140. package/tx/importers/atc-to-fhir.js +17 -16
  141. package/tx/importers/export-icd11-codesystem.js +3 -2
  142. package/tx/importers/import-icd11.js +4 -3
  143. package/tx/importers/import-loinc.module.js +118 -56
  144. package/tx/importers/import-ndc.module.js +56 -44
  145. package/tx/importers/import-rxnorm.module.js +118 -79
  146. package/tx/importers/import-sct.module.js +125 -61
  147. package/tx/importers/import-unii.module.js +37 -36
  148. package/tx/importers/subset-loinc.module.js +49 -48
  149. package/tx/importers/subset-rxnorm.module.js +29 -28
  150. package/tx/importers/terminal.js +18 -0
  151. package/tx/importers/tx-import-base.js +13 -12
  152. package/tx/importers/tx-import-settings.js +5 -4
  153. package/tx/importers/tx-import.js +53 -52
  154. package/tx/library/canonical-resource.js +49 -0
  155. package/tx/library/codesystem.js +94 -5
  156. package/tx/library/designations.js +45 -2
  157. package/tx/library/extensions.js +13 -7
  158. package/tx/library/implicit-valuesets.js +66 -0
  159. package/tx/library/renderer.js +77 -21
  160. package/tx/library/sqlite-utilities.js +2 -0
  161. package/tx/library/tho-precedence.js +40 -0
  162. package/tx/library/ucum-parsers.js +11 -1
  163. package/tx/library/wait-for-cache.js +43 -0
  164. package/tx/library.js +321 -22
  165. package/tx/openapi-operations.js +6 -4
  166. package/tx/operation-context.js +460 -99
  167. package/tx/params.js +25 -0
  168. package/tx/problems.js +3 -5
  169. package/tx/provider.js +314 -67
  170. package/tx/resource-cache-sqlite.js +1050 -0
  171. package/tx/sct/documentation.md +45 -21
  172. package/tx/sct/ecl.js +81 -17
  173. package/tx/sct/editions.js +39 -0
  174. package/tx/sct/expressions.js +10 -1
  175. package/tx/sct/load-config.js +107 -0
  176. package/tx/sct/mmap.js +109 -0
  177. package/tx/sct/structures.js +175 -15
  178. package/tx/sct/text-index.js +5 -1
  179. package/tx/syndication/syndication-feed.js +2 -2
  180. package/tx/tx-html.js +44 -8
  181. package/tx/tx.fhir.org.yml +8 -8
  182. package/tx/tx.js +282 -80
  183. package/tx/usage-tracker.js +39 -11
  184. package/tx/vs/vs-api.js +19 -0
  185. package/tx/vs/vs-database.js +386 -62
  186. package/tx/vs/vs-package.js +44 -17
  187. package/tx/vs/vs-vsac.js +649 -101
  188. package/tx/workers/batch-validate.js +37 -10
  189. package/tx/workers/batch.js +6 -1
  190. package/tx/workers/cache-control.js +15 -4
  191. package/tx/workers/closure.js +156 -13
  192. package/tx/workers/compare.js +8 -5
  193. package/tx/workers/expand.js +1173 -289
  194. package/tx/workers/metadata.js +35 -0
  195. package/tx/workers/search.js +53 -1
  196. package/tx/workers/supplement-filters.js +145 -0
  197. package/tx/workers/translate.js +169 -4
  198. package/tx/workers/validate.js +223 -119
  199. package/tx/workers/worker.js +123 -31
  200. package/tx/xversion/xv-valueset.js +7 -4
  201. package/utilities/submit-test-report.js +3 -1
  202. package/utilities/tx-stress-test.js +482 -0
  203. package/vcl/readme.md +37 -0
  204. package/vcl/vcl-parser.js +15 -0
  205. package/xig/readme.md +52 -0
  206. package/xig/xig-template.html +5 -8
  207. package/xig/xig.js +238 -422
  208. package/changelog-template.md +0 -22
  209. package/registry/test-runner.js +0 -237
  210. package/release.sh +0 -80
  211. package/test-scripts/repro-re2-wasm-leak.js +0 -34
  212. package/tsconfig.eslint.json +0 -9
  213. package/tx/html/dash-metrics.liquid +0 -147
  214. package/tx/html/home-metrics.liquid +0 -225
package/ips/scripts.js ADDED
@@ -0,0 +1,46 @@
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
+ * The module's browser scripts: its own files, read once and served from memory (so no
9
+ * request reaches the file system), with an ETag. A page links a script with its version
10
+ * (?v=, from the script's content), so a browser that has kept a copy picks up a changed
11
+ * script as soon as the server serves it, not when its cached copy expires.
12
+ */
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+ const crypto = require('crypto');
16
+
17
+ const SCRIPTS = new Map();
18
+
19
+ function load(name) {
20
+ if (!SCRIPTS.has(name)) {
21
+ const body = fs.readFileSync(path.join(__dirname, name), 'utf8');
22
+ const hash = crypto.createHash('sha256').update(body).digest('base64url');
23
+ SCRIPTS.set(name, { body, etag: `"${hash.substring(0, 27)}"`, version: hash.substring(0, 12) });
24
+ }
25
+ return SCRIPTS.get(name);
26
+ }
27
+
28
+ /** The route handler that serves the script. */
29
+ function handler(name) {
30
+ const s = load(name);
31
+ return (req, res) => {
32
+ res.setHeader('Cache-Control', 'public, max-age=3600');
33
+ res.setHeader('ETag', s.etag);
34
+ if (req.get('If-None-Match') === s.etag) {
35
+ return res.status(304).end();
36
+ }
37
+ res.type('application/javascript').send(s.body);
38
+ };
39
+ }
40
+
41
+ /** The script's URL, for a page: base/name?v=version (not escaped). */
42
+ function src(base, name) {
43
+ return `${base}/${name}?v=${load(name).version}`;
44
+ }
45
+
46
+ module.exports = { handler, src };
package/ips/store.js CHANGED
@@ -68,6 +68,7 @@ const SCHEMA = `
68
68
  patient_birth TEXT,
69
69
  comp_date TEXT,
70
70
  comp_title TEXT,
71
+ nationality TEXT, -- the nation the document is from (nationality step), if known
71
72
  json TEXT NOT NULL,
72
73
  -- the analysed copy: the document as the processing steps adorned it (pipeline.js)
73
74
  analysed_json TEXT
@@ -102,6 +103,18 @@ const SCHEMA = `
102
103
  json TEXT NOT NULL
103
104
  );
104
105
 
106
+ -- national profiles: the profile a national IG defines for an IPS document (the Bundle), across all projects
107
+ CREATE TABLE IF NOT EXISTS national_profiles (
108
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
109
+ package_id TEXT NOT NULL, -- the IG's package id
110
+ version TEXT NOT NULL, -- the package version
111
+ url TEXT NOT NULL, -- the canonical URL of the IG's profile on Bundle
112
+ jurisdiction TEXT, -- the country (ISO 3166 two-letter code) or region (UN M49 three digits)
113
+ snomed_edition TEXT, -- the SNOMED CT edition the IG takes for granted (its module id), if any
114
+ updated_ms INTEGER NOT NULL,
115
+ UNIQUE (package_id, version)
116
+ );
117
+
105
118
  CREATE TABLE IF NOT EXISTS ips_index (
106
119
  doc_id INTEGER NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
107
120
  res TEXT NOT NULL, -- Bundle | Composition | Patient
@@ -116,6 +129,20 @@ const SCHEMA = `
116
129
  ref TEXT
117
130
  );
118
131
 
132
+ -- each time the processing pipeline was started on a document, and how it ended: running,
133
+ -- finished (whether or not the document was fine), failed (the pipeline threw), or
134
+ -- interrupted (the server stopped while it ran). For the daily report. No foreign key: a run
135
+ -- still happened after its document is deleted
136
+ CREATE TABLE IF NOT EXISTS pipeline_runs (
137
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
138
+ doc_id INTEGER NOT NULL,
139
+ started_ms INTEGER NOT NULL,
140
+ ended_ms INTEGER,
141
+ outcome TEXT NOT NULL
142
+ );
143
+
144
+ CREATE INDEX IF NOT EXISTS idx_pipeline_runs_started ON pipeline_runs(started_ms);
145
+ CREATE INDEX IF NOT EXISTS idx_pipeline_runs_running ON pipeline_runs(outcome, doc_id);
119
146
  CREATE INDEX IF NOT EXISTS idx_documents_project ON documents(project_id, id);
120
147
  CREATE INDEX IF NOT EXISTS idx_documents_status ON documents(status, id);
121
148
  CREATE INDEX IF NOT EXISTS idx_ips_index_doc ON ips_index(doc_id, res, param);
@@ -158,6 +185,11 @@ function summaryOf(bundle) {
158
185
  };
159
186
  }
160
187
 
188
+ /** A national_profiles row, as the store gives it. */
189
+ function nationalProfileOf(r) {
190
+ return { id: r.id, jurisdiction: r.jurisdiction || null, packageId: r.package_id, version: r.version, url: r.url, snomedEdition: r.snomed_edition || null, updated_ms: r.updated_ms };
191
+ }
192
+
161
193
  class IpsStore {
162
194
  /**
163
195
  * @param {string} dbPath - file name, or ':memory:'
@@ -178,7 +210,7 @@ class IpsStore {
178
210
  insertIndex: this.db.prepare(`
179
211
  INSERT INTO ips_index (doc_id, res, param, type, str, str_lc, sys, code, lo, hi, ref)
180
212
  VALUES (@doc_id, @res, @param, @type, @str, @str_lc, @sys, @code, @lo, @hi, @ref)`),
181
- readDoc: this.db.prepare('SELECT id, project_id, received, received_ms, source, status, status_message, processed_ms, size, patient_name, patient_gender, patient_birth, comp_date, comp_title, json, analysed_json FROM documents WHERE id = ?'),
213
+ readDoc: this.db.prepare('SELECT id, project_id, received, received_ms, source, status, status_message, processed_ms, size, patient_name, patient_gender, patient_birth, comp_date, comp_title, nationality, json, analysed_json FROM documents WHERE id = ?'),
182
214
  saveAnalysed: this.db.prepare('UPDATE documents SET analysed_json = ? WHERE id = ?'),
183
215
  deleteDoc: this.db.prepare('DELETE FROM documents WHERE id = ? AND project_id = ?'),
184
216
  countDocs: this.db.prepare('SELECT COUNT(*) AS n FROM documents WHERE project_id = ?'),
@@ -188,12 +220,17 @@ class IpsStore {
188
220
  // only a document being processed is finished: one requeued or deleted meanwhile isn't
189
221
  finish: this.db.prepare("UPDATE documents SET status = ?, status_message = ?, processed_ms = ? WHERE id = ? AND status = 'processing'"),
190
222
  // a document being processed is left alone: its pipeline would finish it over the requeue
191
- requeue: this.db.prepare("UPDATE documents SET status = 'submitted', status_message = NULL, processed_ms = NULL, analysed_json = NULL WHERE id = ? AND project_id = ? AND status <> 'processing'"),
223
+ requeue: this.db.prepare("UPDATE documents SET status = 'submitted', status_message = NULL, processed_ms = NULL, analysed_json = NULL, nationality = NULL WHERE id = ? AND project_id = ? AND status <> 'processing'"),
192
224
  saveResult: this.db.prepare('INSERT OR REPLACE INTO document_results (doc_id, step, made_ms, json) VALUES (?, ?, ?, ?)'),
193
225
  readResult: this.db.prepare('SELECT made_ms, json FROM document_results WHERE doc_id = ? AND step = ?'),
194
226
  resultSteps: this.db.prepare('SELECT step, made_ms FROM document_results WHERE doc_id = ? ORDER BY step'),
195
227
  clearResults: this.db.prepare('DELETE FROM document_results WHERE doc_id = ?'),
196
- resetProcessing: this.db.prepare("UPDATE documents SET status = 'submitted' WHERE status = 'processing'")
228
+ resetProcessing: this.db.prepare("UPDATE documents SET status = 'submitted' WHERE status = 'processing'"),
229
+ startRun: this.db.prepare("INSERT INTO pipeline_runs (doc_id, started_ms, outcome) VALUES (?, ?, 'running')"),
230
+ // the run ends whether or not the document is still there to take the outcome
231
+ endRun: this.db.prepare("UPDATE pipeline_runs SET outcome = ?, ended_ms = ? WHERE doc_id = ? AND outcome = 'running'"),
232
+ interruptRuns: this.db.prepare("UPDATE pipeline_runs SET outcome = 'interrupted', ended_ms = ? WHERE outcome = 'running'"),
233
+ countRuns: this.db.prepare('SELECT outcome, COUNT(*) AS n FROM pipeline_runs WHERE started_ms > ? AND started_ms <= ? GROUP BY outcome')
197
234
  };
198
235
  }
199
236
 
@@ -203,6 +240,15 @@ class IpsStore {
203
240
  if (!has('documents', 'analysed_json')) {
204
241
  this.db.exec('ALTER TABLE documents ADD COLUMN analysed_json TEXT');
205
242
  }
243
+ if (!has('documents', 'nationality')) {
244
+ this.db.exec('ALTER TABLE documents ADD COLUMN nationality TEXT');
245
+ }
246
+ if (!has('national_profiles', 'jurisdiction')) {
247
+ this.db.exec('ALTER TABLE national_profiles ADD COLUMN jurisdiction TEXT');
248
+ }
249
+ if (!has('national_profiles', 'snomed_edition')) {
250
+ this.db.exec('ALTER TABLE national_profiles ADD COLUMN snomed_edition TEXT');
251
+ }
206
252
  if (!has('projects', 'ai_json')) {
207
253
  // the project's AI assessment settings (JSON; the API key encrypted)
208
254
  this.db.exec('ALTER TABLE projects ADD COLUMN ai_json TEXT');
@@ -261,6 +307,42 @@ class IpsStore {
261
307
  return this.db.prepare('DELETE FROM clinical_profiles WHERE id = ?').run(id).changes > 0;
262
308
  }
263
309
 
310
+ // ---- national profiles ----
311
+
312
+ /** All the national profiles: {id, jurisdiction, packageId, version, url, snomedEdition, updated_ms}, by package id and version. */
313
+ nationalProfiles() {
314
+ return this.db.prepare('SELECT id, package_id, version, url, jurisdiction, snomed_edition, updated_ms FROM national_profiles ORDER BY package_id, version, id').all()
315
+ .map(nationalProfileOf);
316
+ }
317
+
318
+ /** @returns {Object|null} {id, jurisdiction, packageId, version, url, snomedEdition, updated_ms} */
319
+ nationalProfile(id) {
320
+ const r = this.db.prepare('SELECT id, package_id, version, url, jurisdiction, snomed_edition, updated_ms FROM national_profiles WHERE id = ?').get(id);
321
+ return r ? nationalProfileOf(r) : null;
322
+ }
323
+
324
+ /** The national profile for a package id and version, or null. */
325
+ nationalProfileByPackage(packageId, version) {
326
+ const r = this.db.prepare('SELECT id FROM national_profiles WHERE package_id = ? AND version = ?').get(packageId, version);
327
+ return r ? this.nationalProfile(r.id) : null;
328
+ }
329
+
330
+ /** @returns {number} the new national profile's id */
331
+ createNationalProfile(packageId, version, url, jurisdiction, snomedEdition = null, nowMs = Date.now()) {
332
+ return Number(this.db.prepare('INSERT INTO national_profiles (package_id, version, url, jurisdiction, snomed_edition, updated_ms) VALUES (?, ?, ?, ?, ?, ?)')
333
+ .run(packageId, version, url, jurisdiction || null, snomedEdition || null, nowMs).lastInsertRowid);
334
+ }
335
+
336
+ /** @returns {boolean} whether there was such a national profile */
337
+ updateNationalProfile(id, packageId, version, url, jurisdiction, snomedEdition = null, nowMs = Date.now()) {
338
+ return this.db.prepare('UPDATE national_profiles SET package_id = ?, version = ?, url = ?, jurisdiction = ?, snomed_edition = ?, updated_ms = ? WHERE id = ?')
339
+ .run(packageId, version, url, jurisdiction || null, snomedEdition || null, nowMs, id).changes > 0;
340
+ }
341
+
342
+ deleteNationalProfile(id) {
343
+ return this.db.prepare('DELETE FROM national_profiles WHERE id = ?').run(id).changes > 0;
344
+ }
345
+
264
346
  // ---- projects ----
265
347
 
266
348
  /** All projects, with their document counts and managers; by name. */
@@ -524,7 +606,7 @@ class IpsStore {
524
606
  return { total, rows: [] };
525
607
  }
526
608
  if (!options.withJson) {
527
- const cols = 'd.id, d.received, d.source, d.status, d.status_message, d.size, d.patient_name, d.patient_gender, d.patient_birth, d.comp_date, d.comp_title';
609
+ const cols = 'd.id, d.received, d.source, d.status, d.status_message, d.size, d.patient_name, d.patient_gender, d.patient_birth, d.comp_date, d.comp_title, d.nationality';
528
610
  const rows = this.db.prepare(`SELECT ${cols} FROM documents d ${w} ORDER BY ${search.orderBy} LIMIT ? OFFSET ?`)
529
611
  .all(...args, ...search.orderArgs, search.count, search.offset);
530
612
  return { total, rows, truncated: false };
@@ -557,19 +639,41 @@ class IpsStore {
557
639
  * Take the oldest submitted document for processing, marking it 'processing'.
558
640
  * @returns {Object|null} the row (as read()) or null if nothing is waiting
559
641
  */
560
- claimNext() {
642
+ claimNext(nowMs = Date.now()) {
561
643
  return this.db.transaction(() => {
562
644
  const next = this.stmts.nextSubmitted.get();
563
645
  if (!next || this.stmts.claim.run(next.id).changes === 0) {
564
646
  return null;
565
647
  }
648
+ this.stmts.startRun.run(next.id, nowMs);
566
649
  return this.read(next.id);
567
650
  })();
568
651
  }
569
652
 
570
- /** Record the outcome of processing a document. */
571
- finish(id, ok, message) {
572
- this.stmts.finish.run(ok ? 'processed' : 'error', message || null, Date.now(), id);
653
+ /**
654
+ * Record the outcome of processing a document: ok, the pipeline finished (the document
655
+ * is processed); not ok, it failed (the document is in error).
656
+ */
657
+ finish(id, ok, message, nowMs = Date.now()) {
658
+ this.db.transaction(() => {
659
+ this.stmts.finish.run(ok ? 'processed' : 'error', message || null, nowMs, id);
660
+ this.stmts.endRun.run(ok ? 'finished' : 'failed', nowMs, id);
661
+ })();
662
+ }
663
+
664
+ /**
665
+ * The pipeline runs started in (sinceMs, nowMs], by how they ended.
666
+ * @returns {{started: number, finished: number, failed: number, interrupted: number, running: number}}
667
+ */
668
+ countRuns(sinceMs, nowMs) {
669
+ const out = { started: 0, finished: 0, failed: 0, interrupted: 0, running: 0 };
670
+ for (const r of this.stmts.countRuns.all(sinceMs, nowMs)) {
671
+ if (Object.prototype.hasOwnProperty.call(out, r.outcome)) {
672
+ out[r.outcome] = r.n;
673
+ }
674
+ out.started += r.n;
675
+ }
676
+ return out;
573
677
  }
574
678
 
575
679
  /**
@@ -621,6 +725,11 @@ class IpsStore {
621
725
  this.stmts.clearResults.run(docId);
622
726
  }
623
727
 
728
+ /** The nation a document is from (an ISO 3166 code), or null: the nationality step's answer, for the list. */
729
+ setNationality(docId, jurisdiction) {
730
+ this.db.prepare('UPDATE documents SET nationality = ? WHERE id = ?').run(jurisdiction || null, docId);
731
+ }
732
+
624
733
  /** The steps that have results for a document. */
625
734
  resultSteps(docId) {
626
735
  return this.stmts.resultSteps.all(docId).map(r => r.step);
@@ -628,10 +737,13 @@ class IpsStore {
628
737
 
629
738
  /**
630
739
  * Anything left 'processing' was being worked on when the server stopped; it goes back
631
- * in the queue. Called at startup. @returns {number} how many
740
+ * in the queue, and its pipeline run was interrupted. Called at startup. @returns {number} how many
632
741
  */
633
- resetProcessing() {
634
- return this.stmts.resetProcessing.run().changes;
742
+ resetProcessing(nowMs = Date.now()) {
743
+ return this.db.transaction(() => {
744
+ this.stmts.interruptRuns.run(nowMs);
745
+ return this.stmts.resetProcessing.run().changes;
746
+ })();
635
747
  }
636
748
 
637
749
  /** How many documents are in each state, for the dashboard. */
@@ -0,0 +1,247 @@
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
+ * The logical structure of an IPS document, as a tree: the Composition, then what it
9
+ * refers to (other than its sections), then the sections, nested, each with what it refers
10
+ * to (entries, author, focus...). Each resource referred to holds what it refers to in turn.
11
+ * A resource is expanded once, at the shallowest place it is met (breadth first, so that one
12
+ * referred to directly by the Composition is expanded there, not inside the collapsed subject
13
+ * that also refers to it); everywhere else it is a 'seen' node, which says where it is. A
14
+ * reference that doesn't resolve in the document is a 'missing' node. References resolve as
15
+ * in a Bundle (fullUrl, absolute or relative to the referring entry's), and #id is a
16
+ * resource contained in the referring resource (or in the resource it is contained in).
17
+ *
18
+ * The structure view (render.js) draws the tree as a tree table; the analysis step and view
19
+ * (analysis.js) walk the same tree.
20
+ *
21
+ * @module ips/structure
22
+ */
23
+
24
+ const escape = require('escape-html');
25
+ const { dateText, describeResource, codeText, quantityText } = require('./presenter');
26
+ const { inBundle } = require('./fhirpath-ips');
27
+
28
+ function text(v) {
29
+ return typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean' ? String(v) : '';
30
+ }
31
+
32
+ function isObject(v) {
33
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
34
+ }
35
+
36
+ /**
37
+ * The references in a resource or a part of one, in document order, as [path, reference]:
38
+ * every Reference with a .reference, at any depth, except under the keys skipped (the
39
+ * Composition's sections, which are shown as sections) and contained resources (shown
40
+ * when something refers to them).
41
+ */
42
+ function referencesIn(node, skip = [], path = '', out = []) {
43
+ if (Array.isArray(node)) {
44
+ node.forEach((v, i) => referencesIn(v, [], `${path}[${i}]`, out));
45
+ } else if (isObject(node)) {
46
+ if (typeof node.reference === 'string') {
47
+ out.push([path, node.reference]);
48
+ }
49
+ for (const [k, v] of Object.entries(node)) {
50
+ if (k !== 'contained' && k !== 'text' && !skip.includes(k) && v !== null && typeof v === 'object') {
51
+ referencesIn(v, [], path ? `${path}.${k}` : k, out);
52
+ }
53
+ }
54
+ }
55
+ return out;
56
+ }
57
+
58
+ // the icon for a resource, by type
59
+ const ICONS = {
60
+ person: ['Patient', 'Practitioner', 'PractitionerRole', 'RelatedPerson', 'Person'],
61
+ organization: ['Organization'],
62
+ medication: ['Medication', 'MedicationStatement', 'MedicationRequest', 'MedicationAdministration', 'MedicationDispense', 'MedicationUsage', 'MedicationKnowledge'],
63
+ audit: ['Provenance', 'AuditEvent'],
64
+ clinical: ['AllergyIntolerance', 'Condition', 'Observation', 'Procedure', 'Immunization', 'ImmunizationRecommendation', 'DiagnosticReport',
65
+ 'DeviceUseStatement', 'DeviceUsage', 'CarePlan', 'ClinicalImpression', 'Flag', 'Consent', 'Encounter', 'ImagingStudy', 'Specimen',
66
+ 'FamilyMemberHistory', 'RiskAssessment', 'Goal', 'ServiceRequest', 'NutritionOrder', 'DocumentReference', 'QuestionnaireResponse']
67
+ };
68
+
69
+ function iconOf(type) {
70
+ return Object.keys(ICONS).find(k => ICONS[k].includes(type)) || 'other';
71
+ }
72
+
73
+ /**
74
+ * What a resource is: a name (a person's, an organization's...), else its code (code,
75
+ * medication, vaccineCode...), with an Observation's value. HTML, escaped.
76
+ */
77
+ function whatOf(r) {
78
+ let what = describeResource(r) || codeText(r.medicationCodeableConcept) || codeText(r.medication?.concept) ||
79
+ (text(r.medicationReference?.display) ? escape(r.medicationReference.display) : '') ||
80
+ codeText(r.medication?.reference ? null : r.medication) ||
81
+ (Array.isArray(r.type) ? codeText(r.type[0]) : codeText(r.type)) || codeText(r.activity) ||
82
+ (Array.isArray(r.category) ? codeText(r.category[0]) : codeText(r.category));
83
+ if (r.resourceType === 'Observation') {
84
+ const value = quantityText(r.valueQuantity) || codeText(r.valueCodeableConcept) ||
85
+ (text(r.valueString) ? escape(r.valueString) : '') || (typeof r.valueBoolean === 'boolean' ? String(r.valueBoolean) : '');
86
+ if (value) {
87
+ what = what ? `${what}: ${value}` : value;
88
+ }
89
+ }
90
+ return what || '';
91
+ }
92
+
93
+ // where to look for when a resource happened, best first: the clinical dates, then the
94
+ // record's own
95
+ const WHEN_PATHS = [
96
+ 'onsetDateTime', 'onsetPeriod.start', 'occurrenceDateTime', 'occurrencePeriod.start', 'performedDateTime', 'performedPeriod.start',
97
+ 'effectiveDateTime', 'effectiveInstant', 'effectivePeriod.start', 'timingDateTime', 'timingPeriod.start', 'whenHandedOver', 'whenPrepared',
98
+ 'authoredOn', 'period.start', 'actualPeriod.start', 'dateTime', 'birthDate', 'recordedDate', 'recorded', 'occurredDateTime',
99
+ 'issued', 'dateAsserted', 'recordedOn', 'created', 'date', 'meta.lastUpdated'
100
+ ];
101
+
102
+ /** When a resource happened: a good try, not a rule. HTML, escaped. */
103
+ function whenOf(r) {
104
+ for (const p of WHEN_PATHS) {
105
+ const v = p.split('.').reduce((o, k) => (isObject(o) ? o[k] : undefined), r);
106
+ if (text(v)) {
107
+ return dateText(v);
108
+ }
109
+ }
110
+ return '';
111
+ }
112
+
113
+ /**
114
+ * The document as a tree. Each node is
115
+ * { kind: 'root'|'section'|'resource'|'seen'|'missing', key, path, fullPath, icon, type, id,
116
+ * ref, what, when, note, open, children, resource?, section?, entry?, container?, shownAt? }
117
+ * - shownAt (seen nodes) is the fullPath of the node where the resource is expanded
118
+ * - path is where the node is in what contains it (subject, entry[0], section[1]); fullPath
119
+ * is the whole of it as FHIRPath from the Composition (Composition.section[0].entry[0].resolve().subject)
120
+ * - key identifies what the node is about, the same each time the tree is made: e{n} for
121
+ * bundle entry n, e{n}#id for a resource contained in it, s:{fullPath} for a section
122
+ * - resource is the resource (resource and seen nodes, and the root), section the section;
123
+ * entry is {key, resource, fullUrl, container} - where references in it resolve from
124
+ * - authors (sections) are the authors that apply to the section: its own, else those of
125
+ * what contains it; parentAuthors are those of what contains it
126
+ *
127
+ * @returns {Object|null} the root, or null when the document has no Composition
128
+ */
129
+ function structureTree(bundle) {
130
+ const entries = Array.isArray(bundle?.entry) ? bundle.entry : [];
131
+ const comp = entries[0]?.resource;
132
+ if (!isObject(comp) || comp.resourceType !== 'Composition') {
133
+ return null;
134
+ }
135
+ const shown = new Map([['e0', 'Composition']]); // key -> fullPath of the node that expands it
136
+
137
+ function resolve(from, ref) {
138
+ if (ref.startsWith('#')) {
139
+ const host = from.container || from;
140
+ if (ref === '#') {
141
+ return host;
142
+ }
143
+ const list = Array.isArray(host.resource.contained) ? host.resource.contained : [];
144
+ const i = list.findIndex(c => isObject(c) && c.id === ref.substring(1));
145
+ return i < 0 ? null : { key: `${host.key}#${ref.substring(1)}`, resource: list[i], fullUrl: host.fullUrl, container: host };
146
+ }
147
+ const found = inBundle(bundle, ref, from.fullUrl);
148
+ return found ? { key: `e${found.index}`, resource: found.resource, fullUrl: entries[found.index].fullUrl } : null;
149
+ }
150
+
151
+ const resourceNode = (path, fullPath, entry, extra = {}) => {
152
+ const r = entry.resource;
153
+ return {
154
+ kind: 'resource', key: entry.key, path, fullPath, icon: iconOf(r.resourceType), type: r.resourceType || '?', id: text(r.id) ? r.id : '',
155
+ what: whatOf(r), when: whenOf(r), note: '', children: [], resource: r, entry, ...extra
156
+ };
157
+ };
158
+
159
+ // The reference nodes are made breadth first: refNodes returns an array with a slot for
160
+ // each reference, and the slots are filled in when the queue gets to them, shallowest
161
+ // first (in the order met, at the same depth). So which node gets to expand a resource
162
+ // depends on how deep it is, not on whether some earlier reference's subtree reached it first.
163
+ const queue = [];
164
+ function refNodes(from, refs, base, depth) {
165
+ const slots = refs.map(() => null);
166
+ queue.push({ from, refs, base, depth, slots });
167
+ return slots;
168
+ }
169
+
170
+ function makeRefNodes({ from, refs, base, depth, slots }) {
171
+ refs.forEach(([path, ref], i) => {
172
+ const fullPath = `${base}.${path}`;
173
+ const target = resolve(from, ref);
174
+ if (!target) {
175
+ const m = /(?:^|\/)([A-Z][A-Za-z]+)\/([^/]+?)(?:\/_history\/[^/]+)?$/.exec(ref);
176
+ slots[i] = { kind: 'missing', key: null, path, fullPath, icon: 'external', type: m ? m[1] : '', id: m ? m[2] : '', ref, what: '', when: '', note: 'not in document', children: [] };
177
+ return;
178
+ }
179
+ const contained = target.container ? 'contained' : '';
180
+ if (shown.has(target.key)) {
181
+ const shownAt = shown.get(target.key);
182
+ slots[i] = resourceNode(path, fullPath, target, { kind: 'seen', ref, shownAt, note: [contained, `shown at ${shownAt}`].filter(Boolean).join(', ') });
183
+ return;
184
+ }
185
+ shown.set(target.key, fullPath);
186
+ const node = resourceNode(path, fullPath, target, { ref, note: contained });
187
+ // a medication statement, request... by reference: what it is is the Medication's
188
+ const med = target.resource.medicationReference?.reference || target.resource.medication?.reference?.reference;
189
+ if (!node.what && typeof med === 'string') {
190
+ const m = resolve(target, med);
191
+ node.what = m ? whatOf(m.resource) : '';
192
+ }
193
+ node.children = refNodes(target, referencesIn(target.resource), `${fullPath}.resolve()`, depth + 1);
194
+ slots[i] = node;
195
+ });
196
+ }
197
+
198
+ function runQueue() {
199
+ while (queue.length > 0) {
200
+ let next = 0;
201
+ for (let i = 1; i < queue.length; i++) {
202
+ if (queue[i].depth < queue[next].depth) {
203
+ next = i;
204
+ }
205
+ }
206
+ makeRefNodes(queue.splice(next, 1)[0]);
207
+ }
208
+ }
209
+
210
+ const rootEntry = { key: 'e0', resource: comp, fullUrl: entries[0].fullUrl };
211
+ const authorsOf = (x) => (Array.isArray(x?.author) ? x.author.filter(isObject) : []);
212
+ function sectionNodes(list, base, prefix, parentAuthors, depth) {
213
+ return (Array.isArray(list) ? list : []).map((sec, i) => [sec, i]).filter(([sec]) => isObject(sec)).map(([sec, i]) => {
214
+ const path = `${prefix}section[${i}]`;
215
+ const fullPath = `Composition.${path}`;
216
+ const title = text(sec.title) ? sec.title : text(sec.code?.text) ? sec.code.text :
217
+ (Array.isArray(sec.code?.coding) && sec.code.coding.find(c => text(c?.display))?.display) || '(untitled section)';
218
+ const authors = authorsOf(sec).length ? authorsOf(sec) : parentAuthors;
219
+ // the reference slots first (filled in by the queue), then the subsections
220
+ const children = refNodes(rootEntry, referencesIn(sec, ['section']), fullPath, depth + 1);
221
+ children.push(...sectionNodes(sec.section, fullPath, `${path}.`, authors, depth + 1));
222
+ return {
223
+ kind: 'section', key: `s:${fullPath}`, path, fullPath, icon: 'section', type: 'Section', id: '', what: `<b>${escape(title)}</b>`, when: '',
224
+ note: children.length ? '' : 'empty', open: true, children, section: sec, entry: rootEntry, authors, parentAuthors, nested: base !== 'Composition'
225
+ };
226
+ });
227
+ }
228
+ const top = { ...resourceNode('Composition', 'Composition', rootEntry, { open: true }), kind: 'root' };
229
+ top.what = text(comp.title) ? escape(comp.title) : top.what;
230
+ top.icon = 'document';
231
+ top.children = refNodes(rootEntry, referencesIn(comp, ['section']), 'Composition', 1);
232
+ top.children.push(...sectionNodes(comp.section, 'Composition', '', authorsOf(comp), 1));
233
+ runQueue();
234
+ top.resolve = resolve;
235
+ return top;
236
+ }
237
+
238
+ /** The nodes of a tree, depth first, as [node, depth, parent]. */
239
+ function flatten(node, depth = 0, parent = null, out = []) {
240
+ out.push([node, depth, parent]);
241
+ for (const c of node.children) {
242
+ flatten(c, depth + 1, node, out);
243
+ }
244
+ return out;
245
+ }
246
+
247
+ module.exports = { structureTree, flatten, referencesIn, whatOf, whenOf, iconOf, isObject, text };
@@ -391,5 +391,5 @@ function terminologyOf(bundle) {
391
391
 
392
392
  module.exports = {
393
393
  terminologyOf, conceptsOf, systemName, describeVersion, sctEdition, linkForSystem, linkForCode, isExternalSystem, isIgnored,
394
- sectionRank, SECTION_ORDER, IGNORED, SYSTEM_NAMES
394
+ sectionRank, SECTION_ORDER, IGNORED, SYSTEM_NAMES, typeOf
395
395
  };
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The largest request body the server's body parsers accept, from server.maxBodyMB
3
+ * (megabytes; default 50). A body is read whole into memory before it is parsed, so this
4
+ * bounds the memory one request can take. A body over it gets a 413 - an OperationOutcome
5
+ * on a tx endpoint (see TXModule.setupEndpoint).
6
+ */
7
+
8
+ const DEFAULT_MAX_BODY_MB = 50;
9
+
10
+ /**
11
+ * @param {Object} serverConfig - the "server" section of config.json
12
+ * @returns {number} the limit in bytes
13
+ */
14
+ function maxBodyBytes(serverConfig) {
15
+ let mb = Number(serverConfig?.maxBodyMB ?? DEFAULT_MAX_BODY_MB);
16
+ if (!Number.isFinite(mb) || mb <= 0) {
17
+ mb = DEFAULT_MAX_BODY_MB; // not a usable number: the default, not no limit
18
+ }
19
+ return Math.round(mb * 1024 * 1024);
20
+ }
21
+
22
+ module.exports = { maxBodyBytes, DEFAULT_MAX_BODY_MB };
@@ -0,0 +1,15 @@
1
+ const crypto = require('crypto');
2
+
3
+ // A random token for the shared resource cache (tx/resource-cache-sqlite.js): 22 FHIR id
4
+ // characters from [A-Za-z0-9-], about 131 random bits. There is no '.' in it, so a cache-id
5
+ // "<code>.<generation>.<random>" splits on '.' into exactly its parts, and the code in front
6
+ // (what a proxy routes on) can't run into a token. With a 16-character code the id is 62
7
+ // characters, inside FHIR's 64.
8
+ const TOKEN = /^[A-Za-z0-9-]{22}$/;
9
+
10
+ function newCacheGeneration() {
11
+ // base64url of 17 bytes, cut to 22 characters; '_' (not an id character) becomes '-'
12
+ return crypto.randomBytes(17).toString('base64url').slice(0, 22).replace(/_/g, '-');
13
+ }
14
+
15
+ module.exports = { newCacheGeneration, TOKEN };