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.
- package/ACCESSIBILITY.md +119 -0
- package/CHANGELOG.md +190 -9
- package/CONFIGURATION.md +305 -0
- package/LICENSE +29 -0
- package/README.md +29 -122
- package/cluster.js +1220 -0
- package/cluster.md +707 -0
- package/config-template.json +77 -8
- package/extension-tracker/extension-tracker-template.html +5 -8
- package/extension-tracker/extension-tracker.js +0 -2
- package/folder/folder.js +7 -6
- package/ips/analysis.js +560 -0
- package/ips/ips-template.html +5 -8
- package/ips/ips.js +331 -33
- package/ips/jurisdictions.js +65 -0
- package/ips/nationality.js +120 -0
- package/ips/pipeline.js +131 -17
- package/ips/presenter.js +1 -1
- package/ips/profile-editor.js +283 -4
- package/ips/readme.md +170 -20
- package/ips/render.js +769 -109
- package/ips/scripts.js +46 -0
- package/ips/store.js +123 -11
- package/ips/structure.js +247 -0
- package/ips/terminology.js +1 -1
- package/library/body-limit.js +22 -0
- package/library/cache-generation.js +15 -0
- package/library/cgroup-memory.js +133 -1
- package/library/cluster-counts.js +121 -0
- package/library/cluster-events.js +153 -0
- package/library/cluster-metrics.js +193 -0
- package/library/cluster-status.js +468 -0
- package/library/config-reader.js +96 -0
- package/library/daily-report.js +100 -2
- package/library/html-server.js +27 -1
- package/library/i18nsupport.js +23 -1
- package/library/keep-alive.js +18 -0
- package/library/languages.js +17 -3
- package/library/logger.js +31 -0
- package/library/memory-guard.js +428 -0
- package/library/native-memory.js +52 -0
- package/library/npm-audit.js +51 -25
- package/library/openapi-doc.js +7 -5
- package/library/package-manager.js +8 -0
- package/library/role.js +197 -0
- package/library/server-credentials.js +169 -0
- package/library/sqlite-utilities.js +59 -0
- package/library/ssrf-guard.js +100 -0
- package/library/static-response-stats.js +16 -0
- package/library/version-utilities.js +50 -0
- package/library/worker-count.js +192 -0
- package/nginx.md +30 -11
- package/npmprojector/indexer.js +18 -10
- package/package.json +11 -8
- package/packages/feed-check-page.js +117 -0
- package/packages/feed-check.js +279 -0
- package/packages/npm-check.js +57 -3
- package/packages/npm-signing.js +219 -0
- package/packages/openapi.yaml +207 -1
- package/packages/package-crawler.js +322 -41
- package/packages/packages-template.html +6 -8
- package/packages/packages.js +604 -57
- package/packages/readme.md +113 -1
- package/publisher/publisher-template.html +2 -5
- package/publisher/publisher.js +121 -110
- package/publisher/readme.md +75 -0
- package/registry/api.js +179 -1
- package/registry/crawler.js +86 -108
- package/registry/documentation/api.md +68 -2
- package/registry/documentation/configuration.md +34 -0
- package/registry/documentation/internals.md +4 -1
- package/registry/documentation/readme.md +3 -2
- package/registry/model.js +8 -0
- package/registry/openapi.yaml +84 -0
- package/registry/registry-template.html +5 -8
- package/registry/registry.js +58 -6
- package/root-bare-template.html +7 -4
- package/root-template.html +5 -8
- package/server.js +398 -198
- package/shl/shl.js +38 -37
- package/static/a11y/a11y-check.js +832 -0
- package/static/a11y/axe.min.js +12 -0
- package/static/assets/css/bootstrap-fhir.css +15 -15
- package/static/assets/images/flags/au.svg +18 -0
- package/static/assets/images/flags/unknown.svg +11 -0
- package/static/assets/images/flags/us.svg +63 -0
- package/static/assets/images/icon_audit.png +0 -0
- package/static/assets/images/icon_clinical.png +0 -0
- package/static/assets/images/icon_document.png +0 -0
- package/static/assets/images/icon_external.png +0 -0
- package/static/assets/images/icon_medication.png +0 -0
- package/static/assets/images/icon_organization.png +0 -0
- package/static/assets/images/icon_other.png +0 -0
- package/static/assets/images/icon_person.png +0 -0
- package/static/assets/images/icon_section.png +0 -0
- package/stats-db.js +45 -0
- package/stats.js +197 -10
- package/testing/testing-template.html +5 -8
- package/token/nginx_passport_setup.md +1 -1
- package/token/security_guide.md +1 -1
- package/token/token-template.html +2 -5
- package/token/token.js +40 -36
- package/translations/Messages.properties +19 -0
- package/tx/README.md +158 -4
- package/tx/closure/closure-store.js +126 -3
- package/tx/cm/cm-database.js +69 -57
- package/tx/cm/cm-package.js +50 -16
- package/tx/cs/cs-api.js +284 -19
- package/tx/cs/cs-api.md +41 -4
- package/tx/cs/cs-areacode.js +1 -1
- package/tx/cs/cs-country.js +17 -5
- package/tx/cs/cs-cpt.js +33 -16
- package/tx/cs/cs-cs.js +199 -24
- package/tx/cs/cs-currency.js +1 -1
- package/tx/cs/cs-icd11.js +21 -13
- package/tx/cs/cs-icf.js +22 -3
- package/tx/cs/cs-lang.js +2 -2
- package/tx/cs/cs-loinc.js +193 -67
- package/tx/cs/cs-mimetypes.js +12 -2
- package/tx/cs/cs-ndc.js +40 -25
- package/tx/cs/cs-omop.js +54 -29
- package/tx/cs/cs-rxnorm.js +300 -196
- package/tx/cs/cs-snomed.js +861 -95
- package/tx/cs/cs-ucum.js +14 -4
- package/tx/cs/cs-unii.js +18 -14
- package/tx/cs/cs-virtual.js +210 -0
- package/tx/ecosystem-resolver.js +413 -0
- package/tx/html/cluster-dash-metrics.liquid +26 -0
- package/tx/html/cluster-home-metrics.liquid +31 -0
- package/tx/html/cluster-metrics-script.liquid +251 -0
- package/tx/html/codesystem-operations.liquid +3 -3
- package/tx/html/ecl-panel.liquid +1 -1
- package/tx/html/external-metrics.liquid +17 -0
- package/tx/html/operations-form.liquid +17 -17
- package/tx/html/search-form.liquid +11 -11
- package/tx/html/status-line.liquid +58 -0
- package/tx/html/status-panel.liquid +140 -0
- package/tx/html/status-tasks.liquid +25 -0
- package/tx/html/tx-template.html +2 -5
- package/tx/importers/atc-to-fhir.js +17 -16
- package/tx/importers/export-icd11-codesystem.js +3 -2
- package/tx/importers/import-icd11.js +4 -3
- package/tx/importers/import-loinc.module.js +118 -56
- package/tx/importers/import-ndc.module.js +56 -44
- package/tx/importers/import-rxnorm.module.js +118 -79
- package/tx/importers/import-sct.module.js +125 -61
- package/tx/importers/import-unii.module.js +37 -36
- package/tx/importers/subset-loinc.module.js +49 -48
- package/tx/importers/subset-rxnorm.module.js +29 -28
- package/tx/importers/terminal.js +18 -0
- package/tx/importers/tx-import-base.js +13 -12
- package/tx/importers/tx-import-settings.js +5 -4
- package/tx/importers/tx-import.js +53 -52
- package/tx/library/canonical-resource.js +49 -0
- package/tx/library/codesystem.js +94 -5
- package/tx/library/designations.js +45 -2
- package/tx/library/extensions.js +13 -7
- package/tx/library/implicit-valuesets.js +66 -0
- package/tx/library/renderer.js +77 -21
- package/tx/library/sqlite-utilities.js +2 -0
- package/tx/library/tho-precedence.js +40 -0
- package/tx/library/ucum-parsers.js +11 -1
- package/tx/library/wait-for-cache.js +43 -0
- package/tx/library.js +321 -22
- package/tx/openapi-operations.js +6 -4
- package/tx/operation-context.js +460 -99
- package/tx/params.js +25 -0
- package/tx/problems.js +3 -5
- package/tx/provider.js +314 -67
- package/tx/resource-cache-sqlite.js +1050 -0
- package/tx/sct/documentation.md +45 -21
- package/tx/sct/ecl.js +81 -17
- package/tx/sct/editions.js +39 -0
- package/tx/sct/expressions.js +10 -1
- package/tx/sct/load-config.js +107 -0
- package/tx/sct/mmap.js +109 -0
- package/tx/sct/structures.js +175 -15
- package/tx/sct/text-index.js +5 -1
- package/tx/syndication/syndication-feed.js +2 -2
- package/tx/tx-html.js +44 -8
- package/tx/tx.fhir.org.yml +8 -8
- package/tx/tx.js +282 -80
- package/tx/usage-tracker.js +39 -11
- package/tx/vs/vs-api.js +19 -0
- package/tx/vs/vs-database.js +386 -62
- package/tx/vs/vs-package.js +44 -17
- package/tx/vs/vs-vsac.js +649 -101
- package/tx/workers/batch-validate.js +37 -10
- package/tx/workers/batch.js +6 -1
- package/tx/workers/cache-control.js +15 -4
- package/tx/workers/closure.js +156 -13
- package/tx/workers/compare.js +8 -5
- package/tx/workers/expand.js +1173 -289
- package/tx/workers/metadata.js +35 -0
- package/tx/workers/search.js +53 -1
- package/tx/workers/supplement-filters.js +145 -0
- package/tx/workers/translate.js +169 -4
- package/tx/workers/validate.js +223 -119
- package/tx/workers/worker.js +123 -31
- package/tx/xversion/xv-valueset.js +7 -4
- package/utilities/submit-test-report.js +3 -1
- package/utilities/tx-stress-test.js +482 -0
- package/vcl/readme.md +37 -0
- package/vcl/vcl-parser.js +15 -0
- package/xig/readme.md +52 -0
- package/xig/xig-template.html +5 -8
- package/xig/xig.js +238 -422
- package/changelog-template.md +0 -22
- package/registry/test-runner.js +0 -237
- package/release.sh +0 -80
- package/test-scripts/repro-re2-wasm-leak.js +0 -34
- package/tsconfig.eslint.json +0 -9
- package/tx/html/dash-metrics.liquid +0 -147
- 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
|
-
/**
|
|
571
|
-
|
|
572
|
-
|
|
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.
|
|
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. */
|
package/ips/structure.js
ADDED
|
@@ -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 };
|
package/ips/terminology.js
CHANGED
|
@@ -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 };
|