fhirsmith 0.18.2 → 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 +149 -0
- 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 +132 -59
- package/ips/nationality.js +120 -0
- package/ips/pipeline.js +74 -21
- package/ips/presenter.js +1 -1
- package/ips/readme.md +132 -25
- package/ips/render.js +466 -77
- package/ips/store.js +79 -22
- 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/crawler.js +84 -108
- package/registry/documentation/configuration.md +34 -0
- package/registry/registry-template.html +5 -8
- package/registry/registry.js +5 -4
- 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 +154 -4
- package/tx/closure/closure-store.js +126 -3
- package/tx/cm/cm-database.js +69 -57
- package/tx/cm/cm-package.js +29 -1
- 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 +148 -14
- 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 +185 -66
- 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 +608 -64
- 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 +57 -58
- 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/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 +278 -42
- package/tx/resource-cache-sqlite.js +1050 -0
- package/tx/sct/ecl.js +47 -6
- 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 +166 -13
- 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 +27 -4
- package/tx/vs/vs-vsac.js +648 -100
- 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/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
|
|
@@ -109,6 +110,7 @@ const SCHEMA = `
|
|
|
109
110
|
version TEXT NOT NULL, -- the package version
|
|
110
111
|
url TEXT NOT NULL, -- the canonical URL of the IG's profile on Bundle
|
|
111
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
|
|
112
114
|
updated_ms INTEGER NOT NULL,
|
|
113
115
|
UNIQUE (package_id, version)
|
|
114
116
|
);
|
|
@@ -127,6 +129,20 @@ const SCHEMA = `
|
|
|
127
129
|
ref TEXT
|
|
128
130
|
);
|
|
129
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);
|
|
130
146
|
CREATE INDEX IF NOT EXISTS idx_documents_project ON documents(project_id, id);
|
|
131
147
|
CREATE INDEX IF NOT EXISTS idx_documents_status ON documents(status, id);
|
|
132
148
|
CREATE INDEX IF NOT EXISTS idx_ips_index_doc ON ips_index(doc_id, res, param);
|
|
@@ -171,7 +187,7 @@ function summaryOf(bundle) {
|
|
|
171
187
|
|
|
172
188
|
/** A national_profiles row, as the store gives it. */
|
|
173
189
|
function nationalProfileOf(r) {
|
|
174
|
-
return { id: r.id, jurisdiction: r.jurisdiction || null, packageId: r.package_id, version: r.version, url: r.url, updated_ms: r.updated_ms };
|
|
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 };
|
|
175
191
|
}
|
|
176
192
|
|
|
177
193
|
class IpsStore {
|
|
@@ -194,7 +210,7 @@ class IpsStore {
|
|
|
194
210
|
insertIndex: this.db.prepare(`
|
|
195
211
|
INSERT INTO ips_index (doc_id, res, param, type, str, str_lc, sys, code, lo, hi, ref)
|
|
196
212
|
VALUES (@doc_id, @res, @param, @type, @str, @str_lc, @sys, @code, @lo, @hi, @ref)`),
|
|
197
|
-
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 = ?'),
|
|
198
214
|
saveAnalysed: this.db.prepare('UPDATE documents SET analysed_json = ? WHERE id = ?'),
|
|
199
215
|
deleteDoc: this.db.prepare('DELETE FROM documents WHERE id = ? AND project_id = ?'),
|
|
200
216
|
countDocs: this.db.prepare('SELECT COUNT(*) AS n FROM documents WHERE project_id = ?'),
|
|
@@ -204,12 +220,17 @@ class IpsStore {
|
|
|
204
220
|
// only a document being processed is finished: one requeued or deleted meanwhile isn't
|
|
205
221
|
finish: this.db.prepare("UPDATE documents SET status = ?, status_message = ?, processed_ms = ? WHERE id = ? AND status = 'processing'"),
|
|
206
222
|
// a document being processed is left alone: its pipeline would finish it over the requeue
|
|
207
|
-
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'"),
|
|
208
224
|
saveResult: this.db.prepare('INSERT OR REPLACE INTO document_results (doc_id, step, made_ms, json) VALUES (?, ?, ?, ?)'),
|
|
209
225
|
readResult: this.db.prepare('SELECT made_ms, json FROM document_results WHERE doc_id = ? AND step = ?'),
|
|
210
226
|
resultSteps: this.db.prepare('SELECT step, made_ms FROM document_results WHERE doc_id = ? ORDER BY step'),
|
|
211
227
|
clearResults: this.db.prepare('DELETE FROM document_results WHERE doc_id = ?'),
|
|
212
|
-
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')
|
|
213
234
|
};
|
|
214
235
|
}
|
|
215
236
|
|
|
@@ -219,9 +240,15 @@ class IpsStore {
|
|
|
219
240
|
if (!has('documents', 'analysed_json')) {
|
|
220
241
|
this.db.exec('ALTER TABLE documents ADD COLUMN analysed_json TEXT');
|
|
221
242
|
}
|
|
243
|
+
if (!has('documents', 'nationality')) {
|
|
244
|
+
this.db.exec('ALTER TABLE documents ADD COLUMN nationality TEXT');
|
|
245
|
+
}
|
|
222
246
|
if (!has('national_profiles', 'jurisdiction')) {
|
|
223
247
|
this.db.exec('ALTER TABLE national_profiles ADD COLUMN jurisdiction TEXT');
|
|
224
248
|
}
|
|
249
|
+
if (!has('national_profiles', 'snomed_edition')) {
|
|
250
|
+
this.db.exec('ALTER TABLE national_profiles ADD COLUMN snomed_edition TEXT');
|
|
251
|
+
}
|
|
225
252
|
if (!has('projects', 'ai_json')) {
|
|
226
253
|
// the project's AI assessment settings (JSON; the API key encrypted)
|
|
227
254
|
this.db.exec('ALTER TABLE projects ADD COLUMN ai_json TEXT');
|
|
@@ -282,15 +309,15 @@ class IpsStore {
|
|
|
282
309
|
|
|
283
310
|
// ---- national profiles ----
|
|
284
311
|
|
|
285
|
-
/** All the national profiles: {id, jurisdiction, packageId, version, url, updated_ms}, by package id and version. */
|
|
312
|
+
/** All the national profiles: {id, jurisdiction, packageId, version, url, snomedEdition, updated_ms}, by package id and version. */
|
|
286
313
|
nationalProfiles() {
|
|
287
|
-
return this.db.prepare('SELECT id, package_id, version, url, jurisdiction, updated_ms FROM national_profiles ORDER BY package_id, version, id').all()
|
|
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()
|
|
288
315
|
.map(nationalProfileOf);
|
|
289
316
|
}
|
|
290
317
|
|
|
291
|
-
/** @returns {Object|null} {id, jurisdiction, packageId, version, url, updated_ms} */
|
|
318
|
+
/** @returns {Object|null} {id, jurisdiction, packageId, version, url, snomedEdition, updated_ms} */
|
|
292
319
|
nationalProfile(id) {
|
|
293
|
-
const r = this.db.prepare('SELECT id, package_id, version, url, jurisdiction, updated_ms FROM national_profiles WHERE id = ?').get(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);
|
|
294
321
|
return r ? nationalProfileOf(r) : null;
|
|
295
322
|
}
|
|
296
323
|
|
|
@@ -301,15 +328,15 @@ class IpsStore {
|
|
|
301
328
|
}
|
|
302
329
|
|
|
303
330
|
/** @returns {number} the new national profile's id */
|
|
304
|
-
createNationalProfile(packageId, version, url, jurisdiction, nowMs = Date.now()) {
|
|
305
|
-
return Number(this.db.prepare('INSERT INTO national_profiles (package_id, version, url, jurisdiction, updated_ms) VALUES (?, ?, ?, ?, ?)')
|
|
306
|
-
.run(packageId, version, url, jurisdiction || null, nowMs).lastInsertRowid);
|
|
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);
|
|
307
334
|
}
|
|
308
335
|
|
|
309
336
|
/** @returns {boolean} whether there was such a national profile */
|
|
310
|
-
updateNationalProfile(id, packageId, version, url, jurisdiction, nowMs = Date.now()) {
|
|
311
|
-
return this.db.prepare('UPDATE national_profiles SET package_id = ?, version = ?, url = ?, jurisdiction = ?, updated_ms = ? WHERE id = ?')
|
|
312
|
-
.run(packageId, version, url, jurisdiction || null, nowMs, id).changes > 0;
|
|
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;
|
|
313
340
|
}
|
|
314
341
|
|
|
315
342
|
deleteNationalProfile(id) {
|
|
@@ -579,7 +606,7 @@ class IpsStore {
|
|
|
579
606
|
return { total, rows: [] };
|
|
580
607
|
}
|
|
581
608
|
if (!options.withJson) {
|
|
582
|
-
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';
|
|
583
610
|
const rows = this.db.prepare(`SELECT ${cols} FROM documents d ${w} ORDER BY ${search.orderBy} LIMIT ? OFFSET ?`)
|
|
584
611
|
.all(...args, ...search.orderArgs, search.count, search.offset);
|
|
585
612
|
return { total, rows, truncated: false };
|
|
@@ -612,19 +639,41 @@ class IpsStore {
|
|
|
612
639
|
* Take the oldest submitted document for processing, marking it 'processing'.
|
|
613
640
|
* @returns {Object|null} the row (as read()) or null if nothing is waiting
|
|
614
641
|
*/
|
|
615
|
-
claimNext() {
|
|
642
|
+
claimNext(nowMs = Date.now()) {
|
|
616
643
|
return this.db.transaction(() => {
|
|
617
644
|
const next = this.stmts.nextSubmitted.get();
|
|
618
645
|
if (!next || this.stmts.claim.run(next.id).changes === 0) {
|
|
619
646
|
return null;
|
|
620
647
|
}
|
|
648
|
+
this.stmts.startRun.run(next.id, nowMs);
|
|
621
649
|
return this.read(next.id);
|
|
622
650
|
})();
|
|
623
651
|
}
|
|
624
652
|
|
|
625
|
-
/**
|
|
626
|
-
|
|
627
|
-
|
|
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;
|
|
628
677
|
}
|
|
629
678
|
|
|
630
679
|
/**
|
|
@@ -676,6 +725,11 @@ class IpsStore {
|
|
|
676
725
|
this.stmts.clearResults.run(docId);
|
|
677
726
|
}
|
|
678
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
|
+
|
|
679
733
|
/** The steps that have results for a document. */
|
|
680
734
|
resultSteps(docId) {
|
|
681
735
|
return this.stmts.resultSteps.all(docId).map(r => r.step);
|
|
@@ -683,10 +737,13 @@ class IpsStore {
|
|
|
683
737
|
|
|
684
738
|
/**
|
|
685
739
|
* Anything left 'processing' was being worked on when the server stopped; it goes back
|
|
686
|
-
* 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
|
|
687
741
|
*/
|
|
688
|
-
resetProcessing() {
|
|
689
|
-
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
|
+
})();
|
|
690
747
|
}
|
|
691
748
|
|
|
692
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 };
|
package/library/cgroup-memory.js
CHANGED
|
@@ -110,4 +110,136 @@ function readCgroupMemoryLimit(opts = {}) {
|
|
|
110
110
|
return { limit: 0, source: null };
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
-
|
|
113
|
+
/**
|
|
114
|
+
* The directory of this process's own cgroup (memory controller), and whether it is v2 or
|
|
115
|
+
* v1. null when there is none to be found (not Linux, or no /proc).
|
|
116
|
+
* @param {Object} [opts] - procFile, cgroupRoot (tests)
|
|
117
|
+
* @returns {{dir: string, version: 2|1}|null}
|
|
118
|
+
*/
|
|
119
|
+
function ownCgroupDir(opts = {}) {
|
|
120
|
+
const procFile = opts.procFile || '/proc/self/cgroup';
|
|
121
|
+
const cgroupRoot = opts.cgroupRoot || '/sys/fs/cgroup';
|
|
122
|
+
let lines;
|
|
123
|
+
try {
|
|
124
|
+
lines = fs.readFileSync(procFile, 'utf8').split('\n').filter(l => l.trim() !== '');
|
|
125
|
+
} catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
const v2 = lines.find(l => l.startsWith('0::'));
|
|
129
|
+
if (v2) {
|
|
130
|
+
const dir = path.join(cgroupRoot, path.posix.normalize(v2.substring(3) || '/'));
|
|
131
|
+
if (fs.existsSync(path.join(dir, 'memory.stat'))) {
|
|
132
|
+
return { dir, version: 2 };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
for (const l of lines) {
|
|
136
|
+
const parts = l.split(':');
|
|
137
|
+
if (parts.length >= 3 && parts[1].split(',').includes('memory')) {
|
|
138
|
+
const dir = path.join(cgroupRoot, 'memory', path.posix.normalize(parts.slice(2).join(':') || '/'));
|
|
139
|
+
if (fs.existsSync(path.join(dir, 'memory.stat'))) {
|
|
140
|
+
return { dir, version: 1 };
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* What memory.stat says a cgroup holds: its private memory (`anon`: what the kernel can't take
|
|
149
|
+
* back without killing a process) and its page cache (`file`: the terminology and SQLite files,
|
|
150
|
+
* mapped or read, which the kernel drops when it needs the room; shared pages counted once).
|
|
151
|
+
* v2: 'anon' and 'file'; v1: 'total_rss' (else 'rss') and 'total_cache' (else 'cache').
|
|
152
|
+
* @param {{dir: string, version: 2|1}} cg - from ownCgroupDir
|
|
153
|
+
* @returns {{anon: number, file: number|null}|null} bytes, or null if it can't be read
|
|
154
|
+
*/
|
|
155
|
+
function readCgroupStat(cg) {
|
|
156
|
+
let text;
|
|
157
|
+
try {
|
|
158
|
+
text = fs.readFileSync(path.join(cg.dir, 'memory.stat'), 'utf8');
|
|
159
|
+
} catch {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
const values = new Map();
|
|
163
|
+
for (const line of text.split('\n')) {
|
|
164
|
+
const i = line.indexOf(' ');
|
|
165
|
+
if (i > 0) {
|
|
166
|
+
values.set(line.substring(0, i), Number(line.substring(i + 1)));
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const first = keys => {
|
|
170
|
+
for (const key of keys) {
|
|
171
|
+
const v = values.get(key);
|
|
172
|
+
if (Number.isFinite(v) && v >= 0) {
|
|
173
|
+
return v;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return null;
|
|
177
|
+
};
|
|
178
|
+
const v2 = cg.version === 2;
|
|
179
|
+
const anon = first(v2 ? ['anon'] : ['total_rss', 'rss']);
|
|
180
|
+
return anon === null ? null : { anon, file: first(v2 ? ['file'] : ['total_cache', 'cache']) };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The private memory of everything in a cgroup (memory.stat 'anon'; see readCgroupStat). The
|
|
185
|
+
* page cache isn't counted: the kernel drops it when it needs the room.
|
|
186
|
+
* @param {{dir: string, version: 2|1}} cg - from ownCgroupDir
|
|
187
|
+
* @returns {number} bytes, or 0 if it can't be read
|
|
188
|
+
*/
|
|
189
|
+
function readCgroupAnon(cg) {
|
|
190
|
+
const stat = readCgroupStat(cg);
|
|
191
|
+
return stat ? stat.anon : 0;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* A process's resident memory by kind, from /proc/<pid>/status: RssAnon (its private memory)
|
|
196
|
+
* and RssFile (the file pages it has mapped and touched, such as the SNOMED CT cache files;
|
|
197
|
+
* pages shared with other processes are counted in each). Linux only.
|
|
198
|
+
* @param {number|string} pid - or 'self'
|
|
199
|
+
* @param {string} [procRoot='/proc'] - (tests)
|
|
200
|
+
* @returns {{anon: number, file: number}|null} bytes, or null if it can't be read
|
|
201
|
+
*/
|
|
202
|
+
function readProcessMemory(pid, procRoot = '/proc') {
|
|
203
|
+
let status;
|
|
204
|
+
try {
|
|
205
|
+
status = fs.readFileSync(path.join(procRoot, String(pid), 'status'), 'utf8');
|
|
206
|
+
} catch {
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
const kb = name => {
|
|
210
|
+
const m = new RegExp(`^${name}:\\s+(\\d+) kB`, 'm').exec(status);
|
|
211
|
+
return m ? Number(m[1]) * 1024 : null;
|
|
212
|
+
};
|
|
213
|
+
const anon = kb('RssAnon');
|
|
214
|
+
return anon === null ? null : { anon, file: kb('RssFile') ?? 0 };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* The RSS of each process in a cgroup, from /proc/<pid>/status. Processes that have gone,
|
|
219
|
+
* or can't be read, are left out.
|
|
220
|
+
* @param {{dir: string}} cg - from ownCgroupDir
|
|
221
|
+
* @returns {Map<number, number>} pid -> bytes
|
|
222
|
+
*/
|
|
223
|
+
function readCgroupProcessRss(cg) {
|
|
224
|
+
const result = new Map();
|
|
225
|
+
let pids;
|
|
226
|
+
try {
|
|
227
|
+
pids = fs.readFileSync(path.join(cg.dir, 'cgroup.procs'), 'utf8').split('\n').filter(Boolean).map(Number);
|
|
228
|
+
} catch {
|
|
229
|
+
return result;
|
|
230
|
+
}
|
|
231
|
+
for (const pid of pids) {
|
|
232
|
+
try {
|
|
233
|
+
const status = fs.readFileSync(`/proc/${pid}/status`, 'utf8');
|
|
234
|
+
const m = /^VmRSS:\s+(\d+) kB/m.exec(status);
|
|
235
|
+
if (m) {
|
|
236
|
+
result.set(pid, Number(m[1]) * 1024);
|
|
237
|
+
}
|
|
238
|
+
} catch {
|
|
239
|
+
// gone
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return result;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
module.exports = { readCgroupMemoryLimit, ownCgroupDir, readCgroupStat, readCgroupAnon, readProcessMemory, readCgroupProcessRss };
|