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
|
@@ -26,6 +26,18 @@
|
|
|
26
26
|
// here reads or writes an unbounded amount in one go: reads that can be large are paged
|
|
27
27
|
// (entriesPage), and the size of what one call can write is capped by the worker.
|
|
28
28
|
//
|
|
29
|
+
// Shared (server.cluster.sharedClosure, cluster.md "Options"): several processes open this
|
|
30
|
+
// file and any of them may answer $closure. The per-table lock is still taken, but it only
|
|
31
|
+
// orders the calls within one process, so correctness across processes comes from the
|
|
32
|
+
// writes themselves. A call computes against the table as it read it (its id and version),
|
|
33
|
+
// and writes in one BEGIN IMMEDIATE transaction that first checks the table is still at
|
|
34
|
+
// that id and version (recordAddChecked); if it isn't, nothing is written and the worker
|
|
35
|
+
// computes again from what is there now. Creating a table checks for an existing one and
|
|
36
|
+
// for the table limit in the same transaction as the insert (createTableChecked). A resync
|
|
37
|
+
// reads only up to the version it reports (entriesPage's upTo), so a write by another
|
|
38
|
+
// process between its pages can't put entries of a later version in it. Without shared,
|
|
39
|
+
// none of these paths is taken.
|
|
40
|
+
//
|
|
29
41
|
|
|
30
42
|
const path = require('path');
|
|
31
43
|
const fs = require('fs');
|
|
@@ -40,10 +52,13 @@ class ClosureStore {
|
|
|
40
52
|
/**
|
|
41
53
|
* @param {Object} config - modules.tx.closure
|
|
42
54
|
* @param {Object} [log]
|
|
55
|
+
* @param {Object} [options]
|
|
56
|
+
* @param {boolean} [options.shared] - other processes use the file too (see above)
|
|
43
57
|
*/
|
|
44
|
-
constructor(config, log) {
|
|
58
|
+
constructor(config, log, options = {}) {
|
|
45
59
|
this.config = config || {};
|
|
46
60
|
this.log = log || console;
|
|
61
|
+
this.shared = !!options.shared;
|
|
47
62
|
this.db = null;
|
|
48
63
|
this.path = ClosureStore.resolvePath(this.config);
|
|
49
64
|
this.locks = new Map(); // table name -> { tail: Promise, count: holder + waiters }
|
|
@@ -69,8 +84,22 @@ class ClosureStore {
|
|
|
69
84
|
fs.mkdirSync(path.dirname(this.path), {recursive: true});
|
|
70
85
|
}
|
|
71
86
|
this.db = new Database(this.path);
|
|
87
|
+
if (this.shared) {
|
|
88
|
+
// first: switching to WAL and creating the tables need locks another process may hold
|
|
89
|
+
this.db.pragma('busy_timeout = 5000');
|
|
90
|
+
}
|
|
72
91
|
this.db.pragma('journal_mode = WAL');
|
|
73
92
|
this.db.pragma('foreign_keys = ON');
|
|
93
|
+
if (this.shared) {
|
|
94
|
+
// one process at a time creates the tables and brings an old database up to date
|
|
95
|
+
this.db.transaction(() => this.#createSchema()).immediate();
|
|
96
|
+
} else {
|
|
97
|
+
this.#createSchema();
|
|
98
|
+
}
|
|
99
|
+
this.#prepare();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
#createSchema() {
|
|
74
103
|
this.db.exec(`
|
|
75
104
|
CREATE TABLE IF NOT EXISTS closure_table (
|
|
76
105
|
id INTEGER PRIMARY KEY,
|
|
@@ -118,6 +147,25 @@ class ClosureStore {
|
|
|
118
147
|
entry_count = (SELECT count(*) FROM closure_entry e WHERE e.table_id = closure_table.id);
|
|
119
148
|
`);
|
|
120
149
|
}
|
|
150
|
+
if (this.shared) {
|
|
151
|
+
// An INTEGER PRIMARY KEY can be reused after a reset/dispose. Shared calls keep
|
|
152
|
+
// the id across awaits, including in their retry memo and paged reads, so it
|
|
153
|
+
// must identify one incarnation forever. A durable counter also upgrades old
|
|
154
|
+
// databases without rebuilding the table and its foreign keys. This runs in
|
|
155
|
+
// the schema's IMMEDIATE transaction, before any shared writer can allocate.
|
|
156
|
+
this.db.exec(`
|
|
157
|
+
CREATE TABLE IF NOT EXISTS closure_table_sequence (
|
|
158
|
+
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
|
159
|
+
last_id INTEGER NOT NULL
|
|
160
|
+
);
|
|
161
|
+
INSERT OR IGNORE INTO closure_table_sequence VALUES (1, 0);
|
|
162
|
+
UPDATE closure_table_sequence SET last_id = max(last_id,
|
|
163
|
+
(SELECT coalesce(max(id), 0) FROM closure_table)) WHERE singleton = 1;
|
|
164
|
+
`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
#prepare() {
|
|
121
169
|
this.stmt = {
|
|
122
170
|
getTable: this.db.prepare('SELECT id, name, version, created, last_used FROM closure_table WHERE name = ?'),
|
|
123
171
|
countTables: this.db.prepare('SELECT count(*) AS n FROM closure_table'),
|
|
@@ -156,6 +204,24 @@ class ClosureStore {
|
|
|
156
204
|
WHERE e.table_id = ? AND e.added_version > ?`),
|
|
157
205
|
staleTables: this.db.prepare('SELECT id, name FROM closure_table WHERE last_used < ?')
|
|
158
206
|
};
|
|
207
|
+
if (this.shared) {
|
|
208
|
+
Object.assign(this.stmt, {
|
|
209
|
+
nextTableId: this.db.prepare('UPDATE closure_table_sequence SET last_id = last_id + 1 WHERE singleton = 1 RETURNING last_id AS id'),
|
|
210
|
+
insertSharedTable: this.db.prepare('INSERT INTO closure_table (id, name, version, created, last_used) VALUES (?, ?, 0, ?, ?)'),
|
|
211
|
+
entriesPageUpTo: this.db.prepare(`
|
|
212
|
+
SELECT e.rowid AS rowid,
|
|
213
|
+
s.system AS source_system, s.code AS source_code, s.display AS source_display,
|
|
214
|
+
t.system AS target_system, t.code AS target_code, t.display AS target_display,
|
|
215
|
+
e.relationship
|
|
216
|
+
FROM closure_entry e
|
|
217
|
+
JOIN closure_concept s ON s.id = e.source_id
|
|
218
|
+
JOIN closure_concept t ON t.id = e.target_id
|
|
219
|
+
WHERE e.table_id = ? AND e.added_version > ? AND e.added_version <= ? AND e.rowid > ?
|
|
220
|
+
ORDER BY e.rowid
|
|
221
|
+
LIMIT ?`),
|
|
222
|
+
deleteStale: this.db.prepare('DELETE FROM closure_table WHERE id = ? AND last_used < ?')
|
|
223
|
+
});
|
|
224
|
+
}
|
|
159
225
|
}
|
|
160
226
|
|
|
161
227
|
close() {
|
|
@@ -260,7 +326,11 @@ class ClosureStore {
|
|
|
260
326
|
* written. Page through with the last row's rowid as afterRowid (start with 0).
|
|
261
327
|
* @returns {Array<{rowid, source_system, source_code, source_display, target_system, target_code, target_display, relationship}>}
|
|
262
328
|
*/
|
|
263
|
-
entriesPage(tableId, version, afterRowid, limit) {
|
|
329
|
+
entriesPage(tableId, version, afterRowid, limit, upTo) {
|
|
330
|
+
if (upTo !== undefined) {
|
|
331
|
+
// shared: only up to the version the caller read (see the header)
|
|
332
|
+
return this.stmt.entriesPageUpTo.all(tableId, version, upTo, afterRowid, limit);
|
|
333
|
+
}
|
|
264
334
|
return this.stmt.entriesPage.all(tableId, version, afterRowid, limit);
|
|
265
335
|
}
|
|
266
336
|
|
|
@@ -278,7 +348,9 @@ class ClosureStore {
|
|
|
278
348
|
if (existing) {
|
|
279
349
|
this.stmt.deleteTable.run(existing.id);
|
|
280
350
|
}
|
|
281
|
-
const info = this.
|
|
351
|
+
const info = this.shared
|
|
352
|
+
? this.stmt.insertSharedTable.run(this.stmt.nextTableId.get().id, name, now, now)
|
|
353
|
+
: this.stmt.insertTable.run(name, now, now);
|
|
282
354
|
const id = info.lastInsertRowid;
|
|
283
355
|
params.forEach((p, i) => this.stmt.insertParam.run(id, i, p.name, JSON.stringify(p)));
|
|
284
356
|
return this.stmt.getTable.get(name);
|
|
@@ -353,6 +425,50 @@ class ClosureStore {
|
|
|
353
425
|
return version;
|
|
354
426
|
}
|
|
355
427
|
|
|
428
|
+
/**
|
|
429
|
+
* Shared: create (or, with reset, replace) a table, checking in the same write
|
|
430
|
+
* transaction that there isn't one already (unless reset) and that the table limit
|
|
431
|
+
* isn't reached - so two processes creating at once can't both succeed, or go over it.
|
|
432
|
+
* @param {string} name
|
|
433
|
+
* @param {Array<Object>} params - as for createTable
|
|
434
|
+
* @param {Object} opts
|
|
435
|
+
* @param {boolean} opts.reset - replace a table of that name
|
|
436
|
+
* @param {number} opts.maxTables - Infinity for none
|
|
437
|
+
* @returns {{table: Object}|{refused: 'exists'|'limit'}}
|
|
438
|
+
*/
|
|
439
|
+
createTableChecked(name, params, { reset, maxTables }) {
|
|
440
|
+
return this.db.transaction(() => {
|
|
441
|
+
const existing = this.stmt.getTable.get(name);
|
|
442
|
+
if (existing && !reset) {
|
|
443
|
+
return { refused: 'exists' };
|
|
444
|
+
}
|
|
445
|
+
if (!existing && this.stmt.countTables.get().n >= maxTables) {
|
|
446
|
+
return { refused: 'limit' };
|
|
447
|
+
}
|
|
448
|
+
return { table: this.createTable(name, params) };
|
|
449
|
+
}).immediate();
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Shared: recordAdd, if the table is still the one the caller computed against - the
|
|
454
|
+
* same id (not reset or dropped since) and the same version (nothing added since). The
|
|
455
|
+
* check and the write are one BEGIN IMMEDIATE transaction, so no other process can
|
|
456
|
+
* write in between.
|
|
457
|
+
* @returns {number|null} the version after the add, or null if the table has changed
|
|
458
|
+
* (nothing was written)
|
|
459
|
+
*/
|
|
460
|
+
recordAddChecked(table, concepts, entries) {
|
|
461
|
+
// Even a duplicate-only add must check: a reset may have removed those concepts
|
|
462
|
+
// while this request was working, or an intervening add changed their version.
|
|
463
|
+
return this.db.transaction(() => {
|
|
464
|
+
const now = this.stmt.getTable.get(table.name);
|
|
465
|
+
if (!now || now.id !== table.id || now.version !== table.version) {
|
|
466
|
+
return null;
|
|
467
|
+
}
|
|
468
|
+
return this.recordAdd(table, concepts, entries);
|
|
469
|
+
}).immediate();
|
|
470
|
+
}
|
|
471
|
+
|
|
356
472
|
/**
|
|
357
473
|
* Every entry added after the given version.
|
|
358
474
|
* @returns {Array<{source_system, source_code, source_display, target_system, target_code, target_display, relationship}>}
|
|
@@ -395,6 +511,13 @@ class ClosureStore {
|
|
|
395
511
|
const dropped = [];
|
|
396
512
|
for (const t of stale) {
|
|
397
513
|
await this.withLock(t.name, async () => {
|
|
514
|
+
if (this.shared) {
|
|
515
|
+
// another process may use the table between a read and a delete: one statement
|
|
516
|
+
if (this.db && this.stmt.deleteStale.run(t.id, cutoff).changes > 0) {
|
|
517
|
+
dropped.push(t.name);
|
|
518
|
+
}
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
398
521
|
const now = this.db ? this.stmt.getTable.get(t.name) : null;
|
|
399
522
|
if (now && now.id === t.id && now.last_used < cutoff) {
|
|
400
523
|
this.stmt.deleteTable.run(t.id);
|
package/tx/cm/cm-database.js
CHANGED
|
@@ -2,6 +2,7 @@ const fs = require('fs').promises;
|
|
|
2
2
|
const sqlite3 = require('sqlite3').verbose();
|
|
3
3
|
const { VersionUtilities } = require('../../library/version-utilities');
|
|
4
4
|
const { ConceptMap } = require('../library/conceptmap');
|
|
5
|
+
const { guarded } = require('../library/sqlite-utilities');
|
|
5
6
|
// Key of the row written once the cache has been fully populated (see isComplete)
|
|
6
7
|
const CACHE_COMPLETE_KEY = 'cache-complete';
|
|
7
8
|
|
|
@@ -41,7 +42,7 @@ class ConceptMapDatabase {
|
|
|
41
42
|
*/
|
|
42
43
|
async create() {
|
|
43
44
|
return new Promise((resolve, reject) => {
|
|
44
|
-
const db = new sqlite3.Database(this.dbPath, (err) => {
|
|
45
|
+
const db = new sqlite3.Database(this.dbPath, guarded(reject, (err) => {
|
|
45
46
|
if (err) {
|
|
46
47
|
reject(new Error(`Failed to create database: ${err.message}`));
|
|
47
48
|
return;
|
|
@@ -132,7 +133,7 @@ class ConceptMapDatabase {
|
|
|
132
133
|
}
|
|
133
134
|
});
|
|
134
135
|
});
|
|
135
|
-
});
|
|
136
|
+
}));
|
|
136
137
|
});
|
|
137
138
|
}
|
|
138
139
|
|
|
@@ -144,16 +145,16 @@ class ConceptMapDatabase {
|
|
|
144
145
|
* @returns {Promise<boolean>}
|
|
145
146
|
*/
|
|
146
147
|
async isComplete() {
|
|
147
|
-
return new Promise((resolve) => {
|
|
148
|
-
const db = new sqlite3.Database(this.dbPath, sqlite3.OPEN_READONLY, (err) => {
|
|
148
|
+
return new Promise((resolve, reject) => {
|
|
149
|
+
const db = new sqlite3.Database(this.dbPath, sqlite3.OPEN_READONLY, guarded(reject, (err) => {
|
|
149
150
|
if (err) {
|
|
150
151
|
resolve(false);
|
|
151
152
|
return;
|
|
152
153
|
}
|
|
153
|
-
db.get('SELECT value FROM cache_status WHERE key = ?', [CACHE_COMPLETE_KEY], (err, row) => {
|
|
154
|
+
db.get('SELECT value FROM cache_status WHERE key = ?', [CACHE_COMPLETE_KEY], guarded(reject, (err, row) => {
|
|
154
155
|
db.close(() => resolve(!err && row != null));
|
|
155
|
-
});
|
|
156
|
-
});
|
|
156
|
+
}));
|
|
157
|
+
}));
|
|
157
158
|
});
|
|
158
159
|
}
|
|
159
160
|
|
|
@@ -164,7 +165,7 @@ class ConceptMapDatabase {
|
|
|
164
165
|
*/
|
|
165
166
|
async markComplete() {
|
|
166
167
|
return new Promise((resolve, reject) => {
|
|
167
|
-
const db = new sqlite3.Database(this.dbPath, (err) => {
|
|
168
|
+
const db = new sqlite3.Database(this.dbPath, guarded(reject, (err) => {
|
|
168
169
|
if (err) {
|
|
169
170
|
reject(new Error(`Failed to open database: ${err.message}`));
|
|
170
171
|
return;
|
|
@@ -173,17 +174,17 @@ class ConceptMapDatabase {
|
|
|
173
174
|
db.run('CREATE TABLE IF NOT EXISTS cache_status (key TEXT PRIMARY KEY, value TEXT)');
|
|
174
175
|
db.run(`INSERT INTO cache_status (key, value) VALUES (?, ?)
|
|
175
176
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
|
176
|
-
[CACHE_COMPLETE_KEY, new Date().toISOString()], (err) => {
|
|
177
|
-
db.close((closeErr) => {
|
|
177
|
+
[CACHE_COMPLETE_KEY, new Date().toISOString()], guarded(reject, (err) => {
|
|
178
|
+
db.close(guarded(reject, (closeErr) => {
|
|
178
179
|
if (err || closeErr) {
|
|
179
180
|
reject(new Error(`Failed to mark cache complete: ${(err || closeErr).message}`));
|
|
180
181
|
} else {
|
|
181
182
|
resolve();
|
|
182
183
|
}
|
|
183
|
-
});
|
|
184
|
-
});
|
|
184
|
+
}));
|
|
185
|
+
}));
|
|
185
186
|
});
|
|
186
|
-
});
|
|
187
|
+
}));
|
|
187
188
|
});
|
|
188
189
|
}
|
|
189
190
|
|
|
@@ -198,28 +199,28 @@ class ConceptMapDatabase {
|
|
|
198
199
|
}
|
|
199
200
|
|
|
200
201
|
return new Promise((resolve, reject) => {
|
|
201
|
-
const db = new sqlite3.Database(this.dbPath, (err) => {
|
|
202
|
+
const db = new sqlite3.Database(this.dbPath, guarded(reject, (err) => {
|
|
202
203
|
if (err) {
|
|
203
204
|
reject(new Error(`Failed to open database: ${err.message}`));
|
|
204
205
|
return;
|
|
205
206
|
}
|
|
206
207
|
|
|
207
208
|
// Step 1: Delete existing related records
|
|
208
|
-
db.run('DELETE FROM conceptmap_identifiers WHERE conceptmap_id = ?', [conceptMap.id], (err) => {
|
|
209
|
+
db.run('DELETE FROM conceptmap_identifiers WHERE conceptmap_id = ?', [conceptMap.id], guarded(reject, (err) => {
|
|
209
210
|
if (err) {
|
|
210
211
|
db.close();
|
|
211
212
|
reject(new Error(`Failed to delete identifiers: ${err.message}`));
|
|
212
213
|
return;
|
|
213
214
|
}
|
|
214
215
|
|
|
215
|
-
db.run('DELETE FROM conceptmap_jurisdictions WHERE conceptmap_id = ?', [conceptMap.id], (err) => {
|
|
216
|
+
db.run('DELETE FROM conceptmap_jurisdictions WHERE conceptmap_id = ?', [conceptMap.id], guarded(reject, (err) => {
|
|
216
217
|
if (err) {
|
|
217
218
|
db.close();
|
|
218
219
|
reject(new Error(`Failed to delete jurisdictions: ${err.message}`));
|
|
219
220
|
return;
|
|
220
221
|
}
|
|
221
222
|
|
|
222
|
-
db.run('DELETE FROM conceptmap_systems WHERE conceptmap_id = ?', [conceptMap.id], (err) => {
|
|
223
|
+
db.run('DELETE FROM conceptmap_systems WHERE conceptmap_id = ?', [conceptMap.id], guarded(reject, (err) => {
|
|
223
224
|
if (err) {
|
|
224
225
|
db.close();
|
|
225
226
|
reject(new Error(`Failed to delete systems: ${err.message}`));
|
|
@@ -250,7 +251,7 @@ class ConceptMapDatabase {
|
|
|
250
251
|
conceptMap.status || null,
|
|
251
252
|
conceptMap.title || null,
|
|
252
253
|
JSON.stringify(conceptMap)
|
|
253
|
-
], (err) => {
|
|
254
|
+
], guarded(reject, (err) => {
|
|
254
255
|
if (err) {
|
|
255
256
|
db.close();
|
|
256
257
|
reject(new Error(`Failed to insert main record: ${err.message}`));
|
|
@@ -259,11 +260,11 @@ class ConceptMapDatabase {
|
|
|
259
260
|
|
|
260
261
|
// Step 3: Insert related records
|
|
261
262
|
this._insertRelatedRecords(db, conceptMap, resolve, reject);
|
|
262
|
-
});
|
|
263
|
-
});
|
|
264
|
-
});
|
|
265
|
-
});
|
|
266
|
-
});
|
|
263
|
+
}));
|
|
264
|
+
}));
|
|
265
|
+
}));
|
|
266
|
+
}));
|
|
267
|
+
}));
|
|
267
268
|
});
|
|
268
269
|
}
|
|
269
270
|
|
|
@@ -393,13 +394,13 @@ class ConceptMapDatabase {
|
|
|
393
394
|
*/
|
|
394
395
|
async loadAllConceptMaps() {
|
|
395
396
|
return new Promise((resolve, reject) => {
|
|
396
|
-
const db = new sqlite3.Database(this.dbPath, sqlite3.OPEN_READONLY, (err) => {
|
|
397
|
+
const db = new sqlite3.Database(this.dbPath, sqlite3.OPEN_READONLY, guarded(reject, (err) => {
|
|
397
398
|
if (err) {
|
|
398
399
|
reject(new Error(`Failed to open database for loading: ${err.message}`));
|
|
399
400
|
return;
|
|
400
401
|
}
|
|
401
402
|
|
|
402
|
-
db.all('SELECT id, url, version, content FROM conceptmaps', [], (err, rows) => {
|
|
403
|
+
db.all('SELECT id, url, version, content FROM conceptmaps', [], guarded(reject, (err, rows) => {
|
|
403
404
|
if (err) {
|
|
404
405
|
db.close();
|
|
405
406
|
reject(new Error(`Failed to load concept maps: ${err.message}`));
|
|
@@ -457,8 +458,8 @@ class ConceptMapDatabase {
|
|
|
457
458
|
db.close();
|
|
458
459
|
reject(new Error(`Failed to parse concept map content: ${error.message}`));
|
|
459
460
|
}
|
|
460
|
-
});
|
|
461
|
-
});
|
|
461
|
+
}));
|
|
462
|
+
}));
|
|
462
463
|
});
|
|
463
464
|
}
|
|
464
465
|
|
|
@@ -473,13 +474,18 @@ class ConceptMapDatabase {
|
|
|
473
474
|
const canOptimize = elements && elements.length > 0 &&
|
|
474
475
|
elements.every(e => INDEXED_COLUMNS.includes(e));
|
|
475
476
|
|
|
476
|
-
// Always include 'id' in the columns to select when optimizing
|
|
477
|
-
|
|
477
|
+
// Always include 'id' in the columns to select when optimizing (and the version, when the
|
|
478
|
+
// results are matched on it - see versionSearchMatches)
|
|
479
|
+
const versionAsked = searchParams.filter(p => p.name.toLowerCase() === 'version').map(p => p.value);
|
|
480
|
+
let columnsToSelect = canOptimize
|
|
478
481
|
? (elements.includes('id') ? elements : ['id', ...elements])
|
|
479
482
|
: null;
|
|
483
|
+
if (columnsToSelect && versionAsked.length > 0 && !columnsToSelect.includes('version')) {
|
|
484
|
+
columnsToSelect = [...columnsToSelect, 'version'];
|
|
485
|
+
}
|
|
480
486
|
|
|
481
487
|
return new Promise((resolve, reject) => {
|
|
482
|
-
const db = new sqlite3.Database(this.dbPath, sqlite3.OPEN_READONLY, (err) => {
|
|
488
|
+
const db = new sqlite3.Database(this.dbPath, sqlite3.OPEN_READONLY, guarded(reject, (err) => {
|
|
483
489
|
if (err) {
|
|
484
490
|
reject(new Error(`Failed to open database for search: ${err.message}`));
|
|
485
491
|
return;
|
|
@@ -487,7 +493,7 @@ class ConceptMapDatabase {
|
|
|
487
493
|
|
|
488
494
|
const { query, params } = this._buildSearchQuery(searchParams, columnsToSelect);
|
|
489
495
|
|
|
490
|
-
db.all(query, params, (err, rows) => {
|
|
496
|
+
db.all(query, params, guarded(reject, (err, rows) => {
|
|
491
497
|
if (err) {
|
|
492
498
|
db.close();
|
|
493
499
|
reject(new Error(`Search query failed: ${err.message}`));
|
|
@@ -522,6 +528,9 @@ class ConceptMapDatabase {
|
|
|
522
528
|
return parsed;
|
|
523
529
|
});
|
|
524
530
|
}
|
|
531
|
+
for (const criteria of versionAsked) {
|
|
532
|
+
results = results.filter(r => VersionUtilities.versionSearchMatches(criteria, r.version, VersionUtilities.versionAlgorithmOf(r)));
|
|
533
|
+
}
|
|
525
534
|
|
|
526
535
|
db.close((err) => {
|
|
527
536
|
if (err) {
|
|
@@ -534,8 +543,8 @@ class ConceptMapDatabase {
|
|
|
534
543
|
db.close();
|
|
535
544
|
reject(new Error(`Failed to parse search results: ${error.message}`));
|
|
536
545
|
}
|
|
537
|
-
});
|
|
538
|
-
});
|
|
546
|
+
}));
|
|
547
|
+
}));
|
|
539
548
|
});
|
|
540
549
|
}
|
|
541
550
|
|
|
@@ -546,14 +555,14 @@ class ConceptMapDatabase {
|
|
|
546
555
|
*/
|
|
547
556
|
async deleteOldConceptMaps(cutoffTimestamp) {
|
|
548
557
|
return new Promise((resolve, reject) => {
|
|
549
|
-
const db = new sqlite3.Database(this.dbPath, (err) => {
|
|
558
|
+
const db = new sqlite3.Database(this.dbPath, guarded(reject, (err) => {
|
|
550
559
|
if (err) {
|
|
551
560
|
reject(new Error(`Failed to open database for cleanup: ${err.message}`));
|
|
552
561
|
return;
|
|
553
562
|
}
|
|
554
563
|
|
|
555
564
|
// Get URLs to delete first
|
|
556
|
-
db.all('SELECT url FROM conceptmaps WHERE last_seen < ?', [cutoffTimestamp], (err, rows) => {
|
|
565
|
+
db.all('SELECT url FROM conceptmaps WHERE last_seen < ?', [cutoffTimestamp], guarded(reject, (err, rows) => {
|
|
557
566
|
if (err) {
|
|
558
567
|
db.close();
|
|
559
568
|
reject(new Error(`Failed to find old records: ${err.message}`));
|
|
@@ -575,7 +584,7 @@ class ConceptMapDatabase {
|
|
|
575
584
|
pendingDeletes--;
|
|
576
585
|
if (pendingDeletes === 0 && !hasError) {
|
|
577
586
|
// Finally delete main records
|
|
578
|
-
db.run('DELETE FROM conceptmaps WHERE last_seen < ?', [cutoffTimestamp], function(err) {
|
|
587
|
+
db.run('DELETE FROM conceptmaps WHERE last_seen < ?', [cutoffTimestamp], guarded(reject, function(err) {
|
|
579
588
|
if (err) {
|
|
580
589
|
db.close();
|
|
581
590
|
reject(new Error(`Failed to delete old records: ${err.message}`));
|
|
@@ -584,7 +593,7 @@ class ConceptMapDatabase {
|
|
|
584
593
|
db.close();
|
|
585
594
|
resolve(deletedCount);
|
|
586
595
|
}
|
|
587
|
-
});
|
|
596
|
+
}));
|
|
588
597
|
}
|
|
589
598
|
};
|
|
590
599
|
|
|
@@ -601,22 +610,22 @@ class ConceptMapDatabase {
|
|
|
601
610
|
|
|
602
611
|
pendingDeletes = 3; // identifiers, jurisdictions, systems
|
|
603
612
|
|
|
604
|
-
db.run(`DELETE FROM conceptmap_identifiers WHERE conceptmap_id IN (${placeholders})`, idsToDelete, (err) => {
|
|
613
|
+
db.run(`DELETE FROM conceptmap_identifiers WHERE conceptmap_id IN (${placeholders})`, idsToDelete, guarded(reject, (err) => {
|
|
605
614
|
if (err) deleteError(new Error(`Failed to delete identifier records: ${err.message}`));
|
|
606
615
|
else deleteComplete();
|
|
607
|
-
});
|
|
616
|
+
}));
|
|
608
617
|
|
|
609
|
-
db.run(`DELETE FROM conceptmap_jurisdictions WHERE conceptmap_id IN (${placeholders})`, idsToDelete, (err) => {
|
|
618
|
+
db.run(`DELETE FROM conceptmap_jurisdictions WHERE conceptmap_id IN (${placeholders})`, idsToDelete, guarded(reject, (err) => {
|
|
610
619
|
if (err) deleteError(new Error(`Failed to delete jurisdiction records: ${err.message}`));
|
|
611
620
|
else deleteComplete();
|
|
612
|
-
});
|
|
621
|
+
}));
|
|
613
622
|
|
|
614
|
-
db.run(`DELETE FROM conceptmap_systems WHERE conceptmap_id IN (${placeholders})`, idsToDelete, (err) => {
|
|
623
|
+
db.run(`DELETE FROM conceptmap_systems WHERE conceptmap_id IN (${placeholders})`, idsToDelete, guarded(reject, (err) => {
|
|
615
624
|
if (err) deleteError(new Error(`Failed to delete system records: ${err.message}`));
|
|
616
625
|
else deleteComplete();
|
|
617
|
-
});
|
|
618
|
-
});
|
|
619
|
-
});
|
|
626
|
+
}));
|
|
627
|
+
}));
|
|
628
|
+
}));
|
|
620
629
|
});
|
|
621
630
|
}
|
|
622
631
|
|
|
@@ -626,7 +635,7 @@ class ConceptMapDatabase {
|
|
|
626
635
|
*/
|
|
627
636
|
async getStatistics() {
|
|
628
637
|
return new Promise((resolve, reject) => {
|
|
629
|
-
const db = new sqlite3.Database(this.dbPath, sqlite3.OPEN_READONLY, (err) => {
|
|
638
|
+
const db = new sqlite3.Database(this.dbPath, sqlite3.OPEN_READONLY, guarded(reject, (err) => {
|
|
630
639
|
if (err) {
|
|
631
640
|
reject(new Error(`Failed to open database for statistics: ${err.message}`));
|
|
632
641
|
return;
|
|
@@ -650,7 +659,7 @@ class ConceptMapDatabase {
|
|
|
650
659
|
};
|
|
651
660
|
|
|
652
661
|
// Total count
|
|
653
|
-
db.get(queries[0], [], (err, row) => {
|
|
662
|
+
db.get(queries[0], [], guarded(reject, (err, row) => {
|
|
654
663
|
if (err) {
|
|
655
664
|
db.close();
|
|
656
665
|
reject(err);
|
|
@@ -658,10 +667,10 @@ class ConceptMapDatabase {
|
|
|
658
667
|
}
|
|
659
668
|
results.totalConceptMaps = row.total;
|
|
660
669
|
checkComplete();
|
|
661
|
-
});
|
|
670
|
+
}));
|
|
662
671
|
|
|
663
672
|
// Status breakdown
|
|
664
|
-
db.all(queries[1], [], (err, rows) => {
|
|
673
|
+
db.all(queries[1], [], guarded(reject, (err, rows) => {
|
|
665
674
|
if (err) {
|
|
666
675
|
db.close();
|
|
667
676
|
reject(err);
|
|
@@ -672,10 +681,10 @@ class ConceptMapDatabase {
|
|
|
672
681
|
results.byStatus[row.status || 'null'] = row.count;
|
|
673
682
|
}
|
|
674
683
|
checkComplete();
|
|
675
|
-
});
|
|
684
|
+
}));
|
|
676
685
|
|
|
677
686
|
// System count
|
|
678
|
-
db.get(queries[2], [], (err, row) => {
|
|
687
|
+
db.get(queries[2], [], guarded(reject, (err, row) => {
|
|
679
688
|
if (err) {
|
|
680
689
|
db.close();
|
|
681
690
|
reject(err);
|
|
@@ -683,8 +692,8 @@ class ConceptMapDatabase {
|
|
|
683
692
|
}
|
|
684
693
|
results.totalSystems = row.systems;
|
|
685
694
|
checkComplete();
|
|
686
|
-
});
|
|
687
|
-
});
|
|
695
|
+
}));
|
|
696
|
+
}));
|
|
688
697
|
});
|
|
689
698
|
}
|
|
690
699
|
|
|
@@ -705,13 +714,16 @@ class ConceptMapDatabase {
|
|
|
705
714
|
|
|
706
715
|
switch (name.toLowerCase()) {
|
|
707
716
|
case 'url':
|
|
708
|
-
|
|
709
|
-
|
|
717
|
+
// a left match (case-sensitive): the url starts with the value
|
|
718
|
+
conditions.push('substr(v.url, 1, length(?)) = ?');
|
|
719
|
+
params.push(value, value);
|
|
710
720
|
break;
|
|
711
721
|
|
|
712
722
|
case 'version':
|
|
713
|
-
|
|
714
|
-
|
|
723
|
+
// version-aware matching (see VersionUtilities.versionSearchMatches): narrowed here to
|
|
724
|
+
// the versions that could match, and matched on the results (see search)
|
|
725
|
+
conditions.push("v.version LIKE ? ESCAPE '\\'");
|
|
726
|
+
params.push(VersionUtilities.versionSearchPrefix(value).replace(/[\\%_]/g, c => '\\' + c) + '%');
|
|
715
727
|
break;
|
|
716
728
|
|
|
717
729
|
case 'name':
|
package/tx/cm/cm-package.js
CHANGED
|
@@ -9,6 +9,7 @@ const {validateParameter} = require("../../library/utilities");
|
|
|
9
9
|
const {ConceptMap} = require("../library/conceptmap");
|
|
10
10
|
const { applyIdPrefix, stripIdPrefix } = require('../library/resource-ids');
|
|
11
11
|
const { keepForPinnedReference } = require('../library/tho-precedence');
|
|
12
|
+
const { waitForCache } = require('../library/wait-for-cache');
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* Package-based ConceptMap provider using shared database layer
|
|
@@ -18,14 +19,19 @@ class PackageConceptMapProvider extends AbstractConceptMapProvider {
|
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
21
|
* @param {PackageContentLoader} packageLoader - Path to the extracted package folder
|
|
22
|
+
* @param {Object} [options]
|
|
23
|
+
* @param {boolean} [options.readOnly] - a tx-worker (cluster.md): the cache is the master's; it
|
|
24
|
+
* is only read (ConceptMapDatabase reads with OPEN_READONLY), waited for until the master has
|
|
25
|
+
* finished it, and never built or changed
|
|
21
26
|
*/
|
|
22
|
-
constructor(packageLoader) {
|
|
27
|
+
constructor(packageLoader, options = {}) {
|
|
23
28
|
super();
|
|
24
29
|
validateParameter(packageLoader, "packageLoader", PackageContentLoader);
|
|
25
30
|
this.packageLoader = packageLoader;
|
|
26
31
|
// -2: caches from before concept maps were checked for active content in their
|
|
27
32
|
// narrative are rebuilt (see initialize)
|
|
28
33
|
this.dbPath = path.join(packageLoader.packageFolder, '.conceptmaps-2.db');
|
|
34
|
+
this.readOnly = !!options.readOnly;
|
|
29
35
|
this.database = new ConceptMapDatabase(this.dbPath);
|
|
30
36
|
this.conceptMapMap = new Map();
|
|
31
37
|
this.initialized = false;
|
|
@@ -41,6 +47,14 @@ class PackageConceptMapProvider extends AbstractConceptMapProvider {
|
|
|
41
47
|
return;
|
|
42
48
|
}
|
|
43
49
|
await this.packageLoader.initialize();
|
|
50
|
+
if (this.readOnly) {
|
|
51
|
+
await waitForCache(async () => (await this.database.exists()) && (await this.database.isComplete()),
|
|
52
|
+
`Package ${this.packageLoader.pid ? this.packageLoader.pid() : ''}: concept map cache ${this.dbPath}`);
|
|
53
|
+
this.conceptMapMap = await this.database.loadAllConceptMaps();
|
|
54
|
+
this.#applyIdPrefix();
|
|
55
|
+
this.initialized = true;
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
44
58
|
await fs.rm(path.join(this.packageLoader.packageFolder, '.conceptmaps.db'), { force: true }); // the old cache
|
|
45
59
|
|
|
46
60
|
// A cache is only used once its population finished. One that was cut short
|
|
@@ -194,6 +208,20 @@ class PackageConceptMapProvider extends AbstractConceptMapProvider {
|
|
|
194
208
|
isMatch = false;
|
|
195
209
|
break;
|
|
196
210
|
}
|
|
211
|
+
} else if (param === 'url') {
|
|
212
|
+
// a left match, on the url as asked for (not lower cased)
|
|
213
|
+
const asked = searchParams.find(sp => sp.name === 'url').value;
|
|
214
|
+
if (!(json.url || '').startsWith(asked)) {
|
|
215
|
+
isMatch = false;
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
} else if (param === 'version') {
|
|
219
|
+
// version-aware matching, on the version as asked for (not lower cased)
|
|
220
|
+
const asked = searchParams.find(sp => sp.name === 'version').value;
|
|
221
|
+
if (!VersionUtilities.versionSearchMatches(asked, json.version, VersionUtilities.versionAlgorithmOf(json))) {
|
|
222
|
+
isMatch = false;
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
197
225
|
} else if (param === 'jurisdiction') {
|
|
198
226
|
// Special handling for jurisdiction - array of CodeableConcept
|
|
199
227
|
if (!this._matchJurisdiction(json.jurisdiction, searchValue)) {
|