fhirsmith 0.13.4 → 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +61 -1
- package/README.md +16 -3
- package/config-template.json +81 -1
- package/library/cgroup-memory.js +113 -0
- package/library/folder-setup.js +8 -1
- package/library/languages.js +15 -0
- package/library/same-origin.js +62 -0
- package/nginx.md +399 -0
- package/package.json +3 -2
- package/publisher/git-remote.js +141 -0
- package/publisher/github-release.js +195 -0
- package/publisher/publisher-template.html +1 -0
- package/publisher/publisher.js +241 -56
- package/registry/registry.js +3 -1
- package/server.js +57 -15
- package/stats.js +19 -13
- package/testing/readme.md +144 -0
- package/testing/render.js +723 -0
- package/testing/search.js +378 -0
- package/testing/store.js +410 -0
- package/testing/testing-template.html +126 -0
- package/testing/testing.js +760 -0
- package/translations/Messages.properties +32 -0
- package/tx/closure/closure-store.js +396 -0
- package/tx/cs/cs-api.js +6 -1
- package/tx/cs/cs-rxnorm.js +35 -8
- package/tx/cs/cs-snomed.js +19 -69
- package/tx/html/codesystem-operations.liquid +177 -0
- package/tx/html/ecl-panel.liquid +93 -0
- package/tx/html/home-metrics.liquid +39 -76
- package/tx/html/tx-template.html +1 -0
- package/tx/library/conceptmap.js +22 -6
- package/tx/library/designations.js +12 -3
- package/tx/library/operation-outcome.js +3 -1
- package/tx/library/renderer.js +42 -5
- package/tx/operation-context.js +80 -17
- package/tx/provider.js +85 -0
- package/tx/sct/editions.js +70 -0
- package/tx/sct/expressions.js +128 -44
- package/tx/tx-html.js +426 -44
- package/tx/tx.js +117 -9
- package/tx/workers/cache-control.js +2 -2
- package/tx/workers/closure.js +483 -13
- package/tx/workers/expand.js +2 -2
- package/tx/workers/metadata.js +8 -3
- package/tx/workers/read.js +18 -3
- package/tx/workers/subsumes.js +18 -2
- package/tx/workers/translate.js +192 -21
- package/tx/workers/validate.js +70 -187
- package/tx/workers/worker.js +12 -35
- package/tx/xversion/xv-conceptmap.js +17 -8
- package/utilities/submit-test-report.js +150 -0
|
@@ -161,6 +161,7 @@ CODESYSTEM_THO_CHECK = Most code systems defined in HL7 IGs will need to move to
|
|
|
161
161
|
CODESYSTEM_TOO_COSTLY_TIME = The operation ''{1}'' took too long to process (>{0}sec) (at {2})
|
|
162
162
|
CODESYSTEM_NOT_ENUMERABLE = The code system ''{0}'' cannot be expanded because its codes cannot be iterated or enumerated in any meaningful sense
|
|
163
163
|
Coding_has_no_system__cannot_validate = Coding has no system. A code with no system has no defined meaning, and it cannot be validated. A system should be provided
|
|
164
|
+
Coding_has_no_system_or_code__cannot_validate = Coding has neither a system nor a code, so it cannot be validated. A system and a code should be provided
|
|
164
165
|
CONCEPTMAP_GROUP_SOURCE_CODE_INVALID = The source code ''{0}'' is not valid in the code system {1}
|
|
165
166
|
CONCEPTMAP_GROUP_SOURCE_CODE_INVALID_VS = The source code ''{0}'' is not valid in the value set {1}
|
|
166
167
|
CONCEPTMAP_GROUP_SOURCE_DISPLAY_INVALID = The source display ''{0}'' for the code ''{2}'' is not valid. Possible displays: {1}
|
|
@@ -514,6 +515,7 @@ MSG_DEPRECATED = Reference to deprecated {2} {0}
|
|
|
514
515
|
MSG_DEPRECATED_SRC = Reference to deprecated {2} {0} from {1}
|
|
515
516
|
MSG_DRAFT = Reference to draft {2} {0}
|
|
516
517
|
MSG_DRAFT_SRC = Reference to draft {2} {0} from {1}
|
|
518
|
+
MSG_DRAFT_SRC_STATUS = Reference to draft {2} {0} from {3} {4} {1}
|
|
517
519
|
MSG_EXPERIMENTAL = Reference to experimental {2} {0}
|
|
518
520
|
MSG_EXPERIMENTAL_SRC = Reference to experimental {2} {0} from {1}
|
|
519
521
|
MSG_RETIRED = Reference to retired {2} {0}
|
|
@@ -1106,6 +1108,36 @@ CACHE_ID_UNKNOWN = The cache ''{0}'' was never issued by this server, and this s
|
|
|
1106
1108
|
CACHE_ID_CLOSED = The cache ''{0}'' was closed {1} ago by the client, using $cache-control?mode=end (it had been open for {2}). It did not expire and it was not idle: something in the client released this cache while this request was still using it
|
|
1107
1109
|
CACHE_ID_EXPIRED = The cache ''{0}'' expired: it went {1} without being used, and this server closes caches that have been idle for more than {2} (this one was dropped {3} ago). Every request that uses a cache resets its idle clock, so a client that keeps using a cache will not see this
|
|
1108
1110
|
CACHE_ID_CLEARED = The cache ''{0}'' was dropped {1} ago, when this server cleared all of its caches
|
|
1111
|
+
CACHE_ID_OTHER_INSTANCE = The cache ''{0}'' was issued by the server instance ''{1}'', but this is instance ''{2}''. A cache only exists on the instance that issued it, so this request reached the wrong server: either the proxy in front of these servers is not routing on the cache-id, or instance ''{1}'' is unavailable
|
|
1112
|
+
CACHE_ID_OTHER_INSTANCE_UNNAMED = The cache ''{0}'' was issued by the server instance ''{1}'', but this server has no instance code, so it cannot have issued it. A cache only exists on the instance that issued it, so this request reached the wrong server: either the proxy in front of these servers is not routing on the cache-id, or instance ''{1}'' is unavailable
|
|
1113
|
+
CLOSURE_NOT_SUPPORTED = This server does not support $closure (it has to be turned on by the administrator)
|
|
1114
|
+
CLOSURE_POST_ONLY = $closure changes the closure table held on the server, so it must be invoked with POST
|
|
1115
|
+
CLOSURE_NAME_REQUIRED = $closure requires a ''name'' parameter naming the closure table
|
|
1116
|
+
CLOSURE_NAME_INVALID = The closure table name ''{0}'' is not valid: it must not contain white space, and must be no more than 64 characters long
|
|
1117
|
+
CLOSURE_NAME_NOT_UUID = The closure table name ''{0}'' is not valid: this server requires closure table names to be UUIDs
|
|
1118
|
+
CLOSURE_PARAM_UNKNOWN = The parameter ''{0}'' is not supported by $closure on this server
|
|
1119
|
+
CLOSURE_CONCEPT_AND_VERSION = A $closure request can add concepts or resynchronise from a version, but not both at once
|
|
1120
|
+
CLOSURE_RESET_AND_VERSION = A $closure request can reset the closure table or resynchronise from a version, but not both at once
|
|
1121
|
+
CLOSURE_TABLE_EXISTS = The closure table ''{0}'' already exists. To discard its content and start again, include the parameter reset = true
|
|
1122
|
+
CLOSURE_TABLE_UNKNOWN = The closure table ''{0}'' does not exist on this server. Initialise it first, with a $closure request that names it and has no concepts
|
|
1123
|
+
CLOSURE_CONFIG_NOT_ALLOWED = The parameter ''{0}'' configures a closure table, so it can only be supplied when the table is initialised (or reset)
|
|
1124
|
+
CLOSURE_VERSION_INVALID = The version ''{0}'' is not valid for the closure table ''{1}'': it must be a whole number between 0 and the table''s current version, {2}
|
|
1125
|
+
CLOSURE_CONCEPT_INVALID = Each concept added to a closure table must be a Coding with a system and a code
|
|
1126
|
+
CLOSURE_TOO_MANY_TABLES = This server holds at most {0} closure tables, and has no room for another
|
|
1127
|
+
CLOSURE_TOO_MANY_CONCEPTS = The closure table ''{0}'' can hold at most {1} concepts on this server
|
|
1128
|
+
CLOSURE_TOO_MANY_REQUEST_CONCEPTS = A single $closure request can add at most {0} concepts on this server
|
|
1129
|
+
CLOSURE_TOO_MANY_ENTRIES = Adding these concepts would put more entries into the closure table ''{0}'' than this server allows, in one request or in total
|
|
1130
|
+
CLOSURE_CONFIG_TOO_LARGE = The configuration supplied for this closure table (mostly the tx-resource parameters) is larger than this server will keep, which is {0} bytes
|
|
1131
|
+
CLOSURE_TABLE_BUSY = The closure table ''{0}'' is busy with other requests. Try again shortly
|
|
1132
|
+
SUBSUMPTION_CS_HIERARCHY_MEANING = The CodeSystem ''{0}'' has hierarchyMeaning = {1}, so its hierarchy does not describe subsumption, and subsumption cannot be tested
|
|
1133
|
+
SUBSUMPTION_CS_NOT_COMPLETE = The CodeSystem ''{0}'' has content = {1}, so subsumption cannot be tested (subsumption can only be tested against a code system with content = complete)
|
|
1134
|
+
SUBSUMPTION_CS_NOT_FOUND = A definition for the CodeSystem ''{0}'' could not be found, so subsumption cannot be tested
|
|
1135
|
+
SUBSUMPTION_CS_NOT_FOUND_VERSION = A definition for the CodeSystem ''{0}'' version ''{1}'' could not be found, so subsumption cannot be tested
|
|
1136
|
+
SUBSUMPTION_NO_CODE = No code is provided for {0}, so subsumption cannot be tested
|
|
1137
|
+
SUBSUMPTION_NO_CODING = No value is provided for {0}, so subsumption cannot be tested
|
|
1138
|
+
SUBSUMPTION_NO_SYSTEM = No code system is provided for {0}, so subsumption cannot be tested
|
|
1139
|
+
SUBSUMPTION_SYSTEM_MISMATCH = Subsumption can only be tested between two codes in the same code system, but the code systems are ''{0}'' and ''{1}''
|
|
1140
|
+
SUBSUMPTION_VERSION_MISMATCH = Subsumption can only be tested between two codes in the same version of ''{0}'', but the versions are ''{1}'' and ''{2}''
|
|
1109
1141
|
Unable_to_validate_code_without_using_server = Unable to validate code without using server because: {0}
|
|
1110
1142
|
Undefined_attribute__on__for_type__properties__ = Undefined attribute ''@{0}'' on {1} for type {2}
|
|
1111
1143
|
Undefined_element_ = Undefined element ''{0}'' at {1}
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
//
|
|
2
|
+
// ClosureStore - persistent storage for $closure tables
|
|
3
|
+
//
|
|
4
|
+
// A closure table is named by the client, lives across sessions (and server restarts),
|
|
5
|
+
// and is shared across FHIR versions: the same name reaches the same table from /r4 and
|
|
6
|
+
// /r5. What's held for each table:
|
|
7
|
+
//
|
|
8
|
+
// closure_table the name, the current version, when it was created / last used, and
|
|
9
|
+
// how many concepts and entries it holds (kept up to date as it grows,
|
|
10
|
+
// so the server's home page can report totals without counting rows)
|
|
11
|
+
// closure_param the parameters the table was initialised with, as an ordered series
|
|
12
|
+
// of name = value pairs (value is the JSON of the Parameters.parameter
|
|
13
|
+
// entry, so tx-resource resources and typed values survive). These are
|
|
14
|
+
// replayed on every later call: they are how the table remembers the
|
|
15
|
+
// code systems a client supplied, and the version / supplement rules
|
|
16
|
+
// closure_concept every concept added, and the version it was added in
|
|
17
|
+
// closure_entry one row per related pair (narrower -> broader, or equivalent), and the
|
|
18
|
+
// version it was added in - which is what makes resync work
|
|
19
|
+
//
|
|
20
|
+
// better-sqlite3 on purpose: every write is synchronous, so a write transaction can never
|
|
21
|
+
// be interleaved with another request's. The only thing that needs a lock is the span of
|
|
22
|
+
// one $closure call on one table - read the table, compute (which awaits, and yields),
|
|
23
|
+
// write - and that is the per-table lock below.
|
|
24
|
+
//
|
|
25
|
+
// Synchronous also means every statement blocks the event loop while it runs, so nothing
|
|
26
|
+
// here reads or writes an unbounded amount in one go: reads that can be large are paged
|
|
27
|
+
// (entriesPage), and the size of what one call can write is capped by the worker.
|
|
28
|
+
//
|
|
29
|
+
|
|
30
|
+
const path = require('path');
|
|
31
|
+
const fs = require('fs');
|
|
32
|
+
const Database = require('better-sqlite3');
|
|
33
|
+
const folders = require('../../library/folder-setup');
|
|
34
|
+
|
|
35
|
+
const NARROWER = 'source-is-narrower-than-target';
|
|
36
|
+
const EQUIVALENT = 'equivalent';
|
|
37
|
+
|
|
38
|
+
class ClosureStore {
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @param {Object} config - modules.tx.closure
|
|
42
|
+
* @param {Object} [log]
|
|
43
|
+
*/
|
|
44
|
+
constructor(config, log) {
|
|
45
|
+
this.config = config || {};
|
|
46
|
+
this.log = log || console;
|
|
47
|
+
this.db = null;
|
|
48
|
+
this.path = ClosureStore.resolvePath(this.config);
|
|
49
|
+
this.locks = new Map(); // table name -> { tail: Promise, count: holder + waiters }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* config.database if given (relative paths resolve under the data folder's databases
|
|
54
|
+
* directory), otherwise closure.db there. ':memory:' is passed through, for tests.
|
|
55
|
+
*/
|
|
56
|
+
static resolvePath(config) {
|
|
57
|
+
const configured = config && config.database;
|
|
58
|
+
if (!configured) {
|
|
59
|
+
return path.join(folders.databasesDir(), 'closure.db');
|
|
60
|
+
}
|
|
61
|
+
if (configured === ':memory:' || path.isAbsolute(configured)) {
|
|
62
|
+
return configured;
|
|
63
|
+
}
|
|
64
|
+
return path.join(folders.databasesDir(), configured);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
open() {
|
|
68
|
+
if (this.path !== ':memory:') {
|
|
69
|
+
fs.mkdirSync(path.dirname(this.path), {recursive: true});
|
|
70
|
+
}
|
|
71
|
+
this.db = new Database(this.path);
|
|
72
|
+
this.db.pragma('journal_mode = WAL');
|
|
73
|
+
this.db.pragma('foreign_keys = ON');
|
|
74
|
+
this.db.exec(`
|
|
75
|
+
CREATE TABLE IF NOT EXISTS closure_table (
|
|
76
|
+
id INTEGER PRIMARY KEY,
|
|
77
|
+
name TEXT NOT NULL UNIQUE,
|
|
78
|
+
version INTEGER NOT NULL DEFAULT 0,
|
|
79
|
+
created TEXT NOT NULL,
|
|
80
|
+
last_used TEXT NOT NULL,
|
|
81
|
+
concept_count INTEGER NOT NULL DEFAULT 0,
|
|
82
|
+
entry_count INTEGER NOT NULL DEFAULT 0
|
|
83
|
+
);
|
|
84
|
+
CREATE TABLE IF NOT EXISTS closure_param (
|
|
85
|
+
table_id INTEGER NOT NULL REFERENCES closure_table(id) ON DELETE CASCADE,
|
|
86
|
+
seq INTEGER NOT NULL,
|
|
87
|
+
name TEXT NOT NULL,
|
|
88
|
+
value TEXT NOT NULL,
|
|
89
|
+
PRIMARY KEY (table_id, seq)
|
|
90
|
+
);
|
|
91
|
+
CREATE TABLE IF NOT EXISTS closure_concept (
|
|
92
|
+
id INTEGER PRIMARY KEY,
|
|
93
|
+
table_id INTEGER NOT NULL REFERENCES closure_table(id) ON DELETE CASCADE,
|
|
94
|
+
system TEXT NOT NULL,
|
|
95
|
+
code TEXT NOT NULL,
|
|
96
|
+
display TEXT,
|
|
97
|
+
added_version INTEGER NOT NULL,
|
|
98
|
+
UNIQUE (table_id, system, code)
|
|
99
|
+
);
|
|
100
|
+
CREATE TABLE IF NOT EXISTS closure_entry (
|
|
101
|
+
table_id INTEGER NOT NULL REFERENCES closure_table(id) ON DELETE CASCADE,
|
|
102
|
+
source_id INTEGER NOT NULL REFERENCES closure_concept(id) ON DELETE CASCADE,
|
|
103
|
+
target_id INTEGER NOT NULL REFERENCES closure_concept(id) ON DELETE CASCADE,
|
|
104
|
+
relationship TEXT NOT NULL,
|
|
105
|
+
added_version INTEGER NOT NULL,
|
|
106
|
+
PRIMARY KEY (table_id, source_id, target_id)
|
|
107
|
+
);
|
|
108
|
+
CREATE INDEX IF NOT EXISTS closure_entry_version ON closure_entry (table_id, added_version);
|
|
109
|
+
`);
|
|
110
|
+
// databases made before the counts were kept: add them, and fill them in once
|
|
111
|
+
const columns = this.db.prepare('PRAGMA table_info(closure_table)').all().map(c => c.name);
|
|
112
|
+
if (!columns.includes('concept_count')) {
|
|
113
|
+
this.db.exec(`
|
|
114
|
+
ALTER TABLE closure_table ADD COLUMN concept_count INTEGER NOT NULL DEFAULT 0;
|
|
115
|
+
ALTER TABLE closure_table ADD COLUMN entry_count INTEGER NOT NULL DEFAULT 0;
|
|
116
|
+
UPDATE closure_table SET
|
|
117
|
+
concept_count = (SELECT count(*) FROM closure_concept c WHERE c.table_id = closure_table.id),
|
|
118
|
+
entry_count = (SELECT count(*) FROM closure_entry e WHERE e.table_id = closure_table.id);
|
|
119
|
+
`);
|
|
120
|
+
}
|
|
121
|
+
this.stmt = {
|
|
122
|
+
getTable: this.db.prepare('SELECT id, name, version, created, last_used FROM closure_table WHERE name = ?'),
|
|
123
|
+
countTables: this.db.prepare('SELECT count(*) AS n FROM closure_table'),
|
|
124
|
+
insertTable: this.db.prepare('INSERT INTO closure_table (name, version, created, last_used) VALUES (?, 0, ?, ?)'),
|
|
125
|
+
deleteTable: this.db.prepare('DELETE FROM closure_table WHERE id = ?'),
|
|
126
|
+
touchTable: this.db.prepare('UPDATE closure_table SET last_used = ? WHERE id = ?'),
|
|
127
|
+
setVersion: this.db.prepare(`UPDATE closure_table SET version = ?, last_used = ?,
|
|
128
|
+
concept_count = concept_count + ?, entry_count = entry_count + ? WHERE id = ?`),
|
|
129
|
+
totals: this.db.prepare(`SELECT count(*) AS tables, coalesce(sum(concept_count), 0) AS concepts,
|
|
130
|
+
coalesce(sum(entry_count), 0) AS entries FROM closure_table`),
|
|
131
|
+
insertParam: this.db.prepare('INSERT INTO closure_param (table_id, seq, name, value) VALUES (?, ?, ?, ?)'),
|
|
132
|
+
getParams: this.db.prepare('SELECT name, value FROM closure_param WHERE table_id = ? ORDER BY seq'),
|
|
133
|
+
getConcepts: this.db.prepare('SELECT id, system, code, display FROM closure_concept WHERE table_id = ?'),
|
|
134
|
+
countConcepts: this.db.prepare('SELECT count(*) AS n FROM closure_concept WHERE table_id = ?'),
|
|
135
|
+
insertConcept: this.db.prepare('INSERT INTO closure_concept (table_id, system, code, display, added_version) VALUES (?, ?, ?, ?, ?)'),
|
|
136
|
+
insertEntry: this.db.prepare('INSERT OR IGNORE INTO closure_entry (table_id, source_id, target_id, relationship, added_version) VALUES (?, ?, ?, ?, ?)'),
|
|
137
|
+
countEntries: this.db.prepare('SELECT count(*) AS n FROM closure_entry WHERE table_id = ?'),
|
|
138
|
+
entriesPage: this.db.prepare(`
|
|
139
|
+
SELECT e.rowid AS rowid,
|
|
140
|
+
s.system AS source_system, s.code AS source_code, s.display AS source_display,
|
|
141
|
+
t.system AS target_system, t.code AS target_code, t.display AS target_display,
|
|
142
|
+
e.relationship
|
|
143
|
+
FROM closure_entry e
|
|
144
|
+
JOIN closure_concept s ON s.id = e.source_id
|
|
145
|
+
JOIN closure_concept t ON t.id = e.target_id
|
|
146
|
+
WHERE e.table_id = ? AND e.added_version > ? AND e.rowid > ?
|
|
147
|
+
ORDER BY e.rowid
|
|
148
|
+
LIMIT ?`),
|
|
149
|
+
entriesSince: this.db.prepare(`
|
|
150
|
+
SELECT s.system AS source_system, s.code AS source_code, s.display AS source_display,
|
|
151
|
+
t.system AS target_system, t.code AS target_code, t.display AS target_display,
|
|
152
|
+
e.relationship
|
|
153
|
+
FROM closure_entry e
|
|
154
|
+
JOIN closure_concept s ON s.id = e.source_id
|
|
155
|
+
JOIN closure_concept t ON t.id = e.target_id
|
|
156
|
+
WHERE e.table_id = ? AND e.added_version > ?`),
|
|
157
|
+
staleTables: this.db.prepare('SELECT id, name FROM closure_table WHERE last_used < ?')
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
close() {
|
|
162
|
+
if (this.db) {
|
|
163
|
+
this.db.close();
|
|
164
|
+
this.db = null;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Run fn with exclusive use of the named table. Calls on different tables run in
|
|
170
|
+
* parallel; calls on the same table queue, in arrival order. fn may await (and yield)
|
|
171
|
+
* as much as it likes - nothing else touches that table until it settles.
|
|
172
|
+
*
|
|
173
|
+
* The queue is bounded, and so is the wait, so a flood of calls on one table can't pile
|
|
174
|
+
* up requests (and their sockets) without limit. A caller that gives up - timeout, or
|
|
175
|
+
* opts.waitFor throwing because the client went away - never runs fn, and passes its
|
|
176
|
+
* turn on to the next in line as soon as it comes.
|
|
177
|
+
*
|
|
178
|
+
* @param {string} name
|
|
179
|
+
* @param {Function} fn - async () => result
|
|
180
|
+
* @param {Object} [opts]
|
|
181
|
+
* @param {number} [opts.maxQueue] - most calls allowed in the queue, counting the one
|
|
182
|
+
* running; beyond that the call fails at once. 0/absent = no limit
|
|
183
|
+
* @param {number} [opts.timeoutMs] - longest to wait for the lock. 0/absent = no limit
|
|
184
|
+
* @param {Function} [opts.waitFor] - (promise) => promise; wraps the wait (the worker
|
|
185
|
+
* uses OperationContext.waitFor, so the wait isn't charged to the compute deadline and
|
|
186
|
+
* a disconnected client stops waiting)
|
|
187
|
+
* @throws {Error} with .closureBusy = 'queue' | 'timeout' when the lock isn't got
|
|
188
|
+
*/
|
|
189
|
+
async withLock(name, fn, opts = {}) {
|
|
190
|
+
let q = this.locks.get(name);
|
|
191
|
+
if (!q) {
|
|
192
|
+
q = { tail: Promise.resolve(), count: 0 };
|
|
193
|
+
this.locks.set(name, q);
|
|
194
|
+
}
|
|
195
|
+
if (opts.maxQueue && q.count >= opts.maxQueue) {
|
|
196
|
+
const error = new Error(`closure table '${name}' is busy`);
|
|
197
|
+
error.closureBusy = 'queue';
|
|
198
|
+
throw error;
|
|
199
|
+
}
|
|
200
|
+
const prior = q.tail;
|
|
201
|
+
let release;
|
|
202
|
+
const mine = new Promise(resolve => { release = resolve; });
|
|
203
|
+
q.tail = prior.then(() => mine);
|
|
204
|
+
q.count++;
|
|
205
|
+
|
|
206
|
+
let timer = null;
|
|
207
|
+
let acquired = false;
|
|
208
|
+
try {
|
|
209
|
+
let wait = prior;
|
|
210
|
+
if (opts.timeoutMs) {
|
|
211
|
+
wait = Promise.race([prior, new Promise((resolve, reject) => {
|
|
212
|
+
timer = setTimeout(() => {
|
|
213
|
+
const error = new Error(`timed out waiting for closure table '${name}'`);
|
|
214
|
+
error.closureBusy = 'timeout';
|
|
215
|
+
reject(error);
|
|
216
|
+
}, opts.timeoutMs);
|
|
217
|
+
})]);
|
|
218
|
+
}
|
|
219
|
+
await (opts.waitFor ? opts.waitFor(wait) : wait);
|
|
220
|
+
acquired = true;
|
|
221
|
+
return await fn();
|
|
222
|
+
} finally {
|
|
223
|
+
if (timer) {
|
|
224
|
+
clearTimeout(timer);
|
|
225
|
+
}
|
|
226
|
+
if (acquired) {
|
|
227
|
+
release();
|
|
228
|
+
} else {
|
|
229
|
+
prior.then(release); // hand the turn on when it comes
|
|
230
|
+
}
|
|
231
|
+
q.count--;
|
|
232
|
+
if (q.count === 0 && this.locks.get(name) === q) {
|
|
233
|
+
this.locks.delete(name);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* @param {string} name
|
|
240
|
+
* @returns {{id, name, version, created, last_used}|null}
|
|
241
|
+
*/
|
|
242
|
+
getTable(name) {
|
|
243
|
+
return this.stmt.getTable.get(name) || null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
tableCount() {
|
|
247
|
+
return this.stmt.countTables.get().n;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
conceptCount(tableId) {
|
|
251
|
+
return this.stmt.countConcepts.get(tableId).n;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
entryCount(tableId) {
|
|
255
|
+
return this.stmt.countEntries.get(tableId).n;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* One page of the entries added after the given version, in the order they were
|
|
260
|
+
* written. Page through with the last row's rowid as afterRowid (start with 0).
|
|
261
|
+
* @returns {Array<{rowid, source_system, source_code, source_display, target_system, target_code, target_display, relationship}>}
|
|
262
|
+
*/
|
|
263
|
+
entriesPage(tableId, version, afterRowid, limit) {
|
|
264
|
+
return this.stmt.entriesPage.all(tableId, version, afterRowid, limit);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Create a table, replacing any table of the same name (the caller has already decided
|
|
269
|
+
* whether that's allowed). Its parameters are fixed here, for the life of the table.
|
|
270
|
+
* @param {string} name
|
|
271
|
+
* @param {Array<Object>} params - Parameters.parameter entries that configure the table
|
|
272
|
+
* @returns {Object} the new table row
|
|
273
|
+
*/
|
|
274
|
+
createTable(name, params) {
|
|
275
|
+
const now = new Date().toISOString();
|
|
276
|
+
const tx = this.db.transaction(() => {
|
|
277
|
+
const existing = this.stmt.getTable.get(name);
|
|
278
|
+
if (existing) {
|
|
279
|
+
this.stmt.deleteTable.run(existing.id);
|
|
280
|
+
}
|
|
281
|
+
const info = this.stmt.insertTable.run(name, now, now);
|
|
282
|
+
const id = info.lastInsertRowid;
|
|
283
|
+
params.forEach((p, i) => this.stmt.insertParam.run(id, i, p.name, JSON.stringify(p)));
|
|
284
|
+
return this.stmt.getTable.get(name);
|
|
285
|
+
});
|
|
286
|
+
return tx();
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* The parameters the table was initialised with, as Parameters.parameter entries.
|
|
291
|
+
*/
|
|
292
|
+
getParams(tableId) {
|
|
293
|
+
return this.stmt.getParams.all(tableId).map(r => JSON.parse(r.value));
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* @returns {Array<{id, system, code, display}>}
|
|
298
|
+
*/
|
|
299
|
+
getConcepts(tableId) {
|
|
300
|
+
return this.stmt.getConcepts.all(tableId);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
touch(tableId) {
|
|
304
|
+
this.stmt.touchTable.run(new Date().toISOString(), tableId);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Record the outcome of an add, all at once: the new concepts, the new entries, and the
|
|
309
|
+
* table's new version. Nothing is written unless everything is.
|
|
310
|
+
*
|
|
311
|
+
* @param {Object} table - the table row (id, version)
|
|
312
|
+
* @param {Array<{key, system, code, display}>} concepts - new concepts; key identifies
|
|
313
|
+
* the concept in the entries below
|
|
314
|
+
* @param {Array<{source, target, relationship}>} entries - source/target are either the
|
|
315
|
+
* id of an existing concept (number) or the key of a new one (string)
|
|
316
|
+
* @returns {number} the table's version after the add
|
|
317
|
+
*/
|
|
318
|
+
recordAdd(table, concepts, entries) {
|
|
319
|
+
if (concepts.length === 0) {
|
|
320
|
+
this.touch(table.id);
|
|
321
|
+
return table.version;
|
|
322
|
+
}
|
|
323
|
+
const version = table.version + 1;
|
|
324
|
+
const now = new Date().toISOString();
|
|
325
|
+
const tx = this.db.transaction(() => {
|
|
326
|
+
const ids = new Map();
|
|
327
|
+
for (const c of concepts) {
|
|
328
|
+
const info = this.stmt.insertConcept.run(table.id, c.system, c.code, c.display || null, version);
|
|
329
|
+
ids.set(c.key, info.lastInsertRowid);
|
|
330
|
+
}
|
|
331
|
+
const id = ref => (typeof ref === 'string' ? ids.get(ref) : ref);
|
|
332
|
+
let added = 0;
|
|
333
|
+
for (const e of entries) {
|
|
334
|
+
added += this.stmt.insertEntry.run(table.id, id(e.source), id(e.target), e.relationship, version).changes;
|
|
335
|
+
}
|
|
336
|
+
this.stmt.setVersion.run(version, now, concepts.length, added, table.id);
|
|
337
|
+
});
|
|
338
|
+
tx();
|
|
339
|
+
return version;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Every entry added after the given version.
|
|
344
|
+
* @returns {Array<{source_system, source_code, source_display, target_system, target_code, target_display, relationship}>}
|
|
345
|
+
*/
|
|
346
|
+
entriesSince(tableId, version) {
|
|
347
|
+
return this.stmt.entriesSince.all(tableId, version);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Totals across every table, and the size of the database on disk (the main file plus
|
|
352
|
+
* the write-ahead log; null for an in-memory database). Cheap: the counts are kept on
|
|
353
|
+
* each table row, so this reads at most maxTables rows.
|
|
354
|
+
* @returns {{tables: number, concepts: number, entries: number, bytes: number|null}}
|
|
355
|
+
*/
|
|
356
|
+
totals() {
|
|
357
|
+
const t = this.stmt.totals.get();
|
|
358
|
+
let bytes = null;
|
|
359
|
+
if (this.path !== ':memory:') {
|
|
360
|
+
bytes = 0;
|
|
361
|
+
for (const f of [this.path, this.path + '-wal']) {
|
|
362
|
+
try {
|
|
363
|
+
bytes += fs.statSync(f).size;
|
|
364
|
+
} catch (e) {
|
|
365
|
+
// no WAL file yet
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return { tables: t.tables, concepts: t.concepts, entries: t.entries, bytes };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Drop tables that nobody has used for the given number of days. Each table is dropped
|
|
374
|
+
* under its lock (a table in use is not pulled out from under the call using it - it is
|
|
375
|
+
* checked again once the lock is held), one at a time, yielding in between.
|
|
376
|
+
* @returns {Promise<Array<string>>} the names dropped
|
|
377
|
+
*/
|
|
378
|
+
async pruneUnused(days) {
|
|
379
|
+
const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
|
|
380
|
+
const stale = this.stmt.staleTables.all(cutoff);
|
|
381
|
+
const dropped = [];
|
|
382
|
+
for (const t of stale) {
|
|
383
|
+
await this.withLock(t.name, async () => {
|
|
384
|
+
const now = this.db ? this.stmt.getTable.get(t.name) : null;
|
|
385
|
+
if (now && now.id === t.id && now.last_used < cutoff) {
|
|
386
|
+
this.stmt.deleteTable.run(t.id);
|
|
387
|
+
dropped.push(t.name);
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
391
|
+
}
|
|
392
|
+
return dropped;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
module.exports = { ClosureStore, NARROWER, EQUIVALENT };
|
package/tx/cs/cs-api.js
CHANGED
|
@@ -67,7 +67,12 @@ class CodeSystemProvider {
|
|
|
67
67
|
*/
|
|
68
68
|
|
|
69
69
|
/**
|
|
70
|
-
*
|
|
70
|
+
* The display name of the code system - what $lookup reports as its name parameter, and
|
|
71
|
+
* what a person reads. Every provider should override this; the fallback is the
|
|
72
|
+
* versioned uri, which is not a name, and a provider that forgets ends up reporting
|
|
73
|
+
* "http://snomed.info/sct|http://snomed.info/sct/731000124108/version/20230301" where
|
|
74
|
+
* the others report "LOINC".
|
|
75
|
+
* @returns {string} display name for the code system
|
|
71
76
|
*/
|
|
72
77
|
name() { return this.system() + (this.version() ? "|"+this.version() : "") }
|
|
73
78
|
|
package/tx/cs/cs-rxnorm.js
CHANGED
|
@@ -428,6 +428,20 @@ class RxNormServices extends CodeSystemProvider {
|
|
|
428
428
|
filterContext.filters.push(filter);
|
|
429
429
|
}
|
|
430
430
|
|
|
431
|
+
/**
|
|
432
|
+
* Whether RXNSTEMS can hold this stem at all.
|
|
433
|
+
*
|
|
434
|
+
* The importer only stems words that are longer than two characters and start with a
|
|
435
|
+
* letter (see extractWords), so the table has nothing that begins with a digit. Every
|
|
436
|
+
* token of a search is ANDed, so a token the table cannot hold takes the whole search
|
|
437
|
+
* to nothing: "acetaminophen" found thousands of codes while "acetaminophen 500" found
|
|
438
|
+
* none, which is not a result anyone would expect from adding a dose.
|
|
439
|
+
* @private
|
|
440
|
+
*/
|
|
441
|
+
#stemIsIndexed(stem) {
|
|
442
|
+
return stem.length > 2 && /^[a-z]/.test(stem);
|
|
443
|
+
}
|
|
444
|
+
|
|
431
445
|
async searchFilter(filterContext, filter, sort) {
|
|
432
446
|
|
|
433
447
|
if (!filter || !filter.stems || filter.stems.length === 0) {
|
|
@@ -437,9 +451,18 @@ class RxNormServices extends CodeSystemProvider {
|
|
|
437
451
|
for (let i = 0; i < filter.stems.length; i++) {
|
|
438
452
|
const stem = filter.stems[i];
|
|
439
453
|
const rxnormFilter = new RxNormFilterHolder();
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
454
|
+
if (this.#stemIsIndexed(stem)) {
|
|
455
|
+
rxnormFilter.text = true;
|
|
456
|
+
rxnormFilter.sql = ` AND (${this.getCodeField()} = s${i}.CUI AND s${i}.stem LIKE $stem${i})`;
|
|
457
|
+
rxnormFilter.params[`stem${i}`] = stem + '%';
|
|
458
|
+
} else {
|
|
459
|
+
// Not in the stem table, so match the token against the text itself. Left as a
|
|
460
|
+
// plain (non-text) filter so that executeFilters does not join RXNSTEMS for it.
|
|
461
|
+
// Unindexed, but it only ever narrows rows the other tokens have already found,
|
|
462
|
+
// and the tokens are alphanumeric, so there are no LIKE wildcards to escape.
|
|
463
|
+
rxnormFilter.sql = ` AND STR LIKE $text${i}`;
|
|
464
|
+
rxnormFilter.params[`text${i}`] = '%' + ((filter.words && filter.words[i]) || stem) + '%';
|
|
465
|
+
}
|
|
443
466
|
|
|
444
467
|
filterContext.filters.push(rxnormFilter);
|
|
445
468
|
}
|
|
@@ -470,17 +493,21 @@ class RxNormServices extends CodeSystemProvider {
|
|
|
470
493
|
}
|
|
471
494
|
}
|
|
472
495
|
|
|
473
|
-
// Add text search joins and filters
|
|
496
|
+
// Add text search joins and filters. Each one is renumbered to its position among the
|
|
497
|
+
// text filters, which is not its position among the filters - searchFilter leaves gaps
|
|
498
|
+
// where a token went to a plain STR match instead. The alias, the placeholder and the
|
|
499
|
+
// parameter key all carry that number and have to move together: renaming the key but
|
|
500
|
+
// not the placeholder leaves the query asking for a parameter that is not there.
|
|
474
501
|
for (const filter of filterContext.filters) {
|
|
475
502
|
if (filter.text) {
|
|
476
503
|
sql2 += `, rxnstems as s${stemIndex}`;
|
|
477
|
-
const stemSql = filter.sql
|
|
504
|
+
const stemSql = filter.sql
|
|
505
|
+
.replace(/\bs\d+\./g, `s${stemIndex}.`)
|
|
506
|
+
.replace(/\$stem\d+/g, `$stem${stemIndex}`);
|
|
478
507
|
sql1 += ' ' + stemSql;
|
|
479
508
|
|
|
480
|
-
// Update parameter keys to match stem index
|
|
481
509
|
for (const [key, value] of Object.entries(filter.params)) {
|
|
482
|
-
|
|
483
|
-
allParams[newKey] = value;
|
|
510
|
+
allParams[key.replace(/\d+$/, stemIndex.toString())] = value;
|
|
484
511
|
}
|
|
485
512
|
stemIndex++;
|
|
486
513
|
}
|
package/tx/cs/cs-snomed.js
CHANGED
|
@@ -16,6 +16,7 @@ const {ConceptMap} = require("../library/conceptmap");
|
|
|
16
16
|
const {ECLLexer, ECLParser, ECLNodeType, ECLTokenType} = require("../sct/ecl");
|
|
17
17
|
const {Issue} = require("../library/operation-outcome");
|
|
18
18
|
const {debugLog} = require("../operation-context");
|
|
19
|
+
const {editionName, editionCode} = require("../sct/editions");
|
|
19
20
|
const {SnomedTextIndex, rankMatches, filterTokens} = require("../sct/text-index");
|
|
20
21
|
const Logger = require("../../library/logger");
|
|
21
22
|
|
|
@@ -1676,6 +1677,19 @@ class SnomedProvider extends BaseCSServices {
|
|
|
1676
1677
|
return actualVersion && actualVersion.startsWith(checkVersion);
|
|
1677
1678
|
}
|
|
1678
1679
|
|
|
1680
|
+
/**
|
|
1681
|
+
* The display name of the code system, as $lookup reports it.
|
|
1682
|
+
*
|
|
1683
|
+
* Without this, CodeSystemProvider's fallback applies, which returns the versioned uri -
|
|
1684
|
+
* so a SNOMED lookup answered "http://snomed.info/sct|http://snomed.info/sct/731000124108
|
|
1685
|
+
* /version/20230301" where every other code system answers something like "LOINC". The
|
|
1686
|
+
* edition is named because for SNOMED that is the part worth knowing; the version
|
|
1687
|
+
* parameter carries the uri.
|
|
1688
|
+
*/
|
|
1689
|
+
name() {
|
|
1690
|
+
return this.sct.getDescription();
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1679
1693
|
description() {
|
|
1680
1694
|
return this.sct.getDescription();
|
|
1681
1695
|
}
|
|
@@ -2787,7 +2801,7 @@ class SnomedServicesFactory extends CodeSystemFactoryProvider {
|
|
|
2787
2801
|
|
|
2788
2802
|
if (url.startsWith('http://snomed.info/sct?fhir_vs') ||
|
|
2789
2803
|
url.startsWith(`http://snomed.info/sct/${this.edition}?fhir_vs`) ||
|
|
2790
|
-
url.startsWith(`http://snomed.info/sct/${this.edition}/version/${this.version}?fhir_vs`)) {
|
|
2804
|
+
url.startsWith(`http://snomed.info/sct/${this.edition}/version/${this.version()}?fhir_vs`)) {
|
|
2791
2805
|
id = url.substring(qIdx);
|
|
2792
2806
|
} else {
|
|
2793
2807
|
return null;
|
|
@@ -3005,8 +3019,8 @@ class SnomedServicesFactory extends CodeSystemFactoryProvider {
|
|
|
3005
3019
|
description: `The concept map implicitly defined by the ${name} Association Reference Set`,
|
|
3006
3020
|
copyright: 'This value set includes content from SNOMED CT, which is copyright © 2002+ International Health Terminology Standards Development Organisation (SNOMED International), and distributed by agreement between SNOMED International and HL7',
|
|
3007
3021
|
status: 'active',
|
|
3008
|
-
sourceUri: `${this.system}?fhir_vs`,
|
|
3009
|
-
targetUri: `${this.system}?fhir_vs`,
|
|
3022
|
+
sourceUri: `${this.system()}?fhir_vs`,
|
|
3023
|
+
targetUri: `${this.system()}?fhir_vs`,
|
|
3010
3024
|
group: [{
|
|
3011
3025
|
source: 'http://snomed.info/sct',
|
|
3012
3026
|
target: 'http://snomed.info/sct'
|
|
@@ -3025,75 +3039,11 @@ class SnomedServicesFactory extends CodeSystemFactoryProvider {
|
|
|
3025
3039
|
}
|
|
3026
3040
|
|
|
3027
3041
|
function getEditionName(edition) {
|
|
3028
|
-
|
|
3029
|
-
'900000000000207008': 'International Edition',
|
|
3030
|
-
'449081005': 'International Spanish Edition',
|
|
3031
|
-
'11000221109': 'Argentinian Edition',
|
|
3032
|
-
'32506021000036107': 'Australian Edition (with drug extension)',
|
|
3033
|
-
'11000234105': 'Austrian Edition',
|
|
3034
|
-
'11000172109': 'Belgian Edition',
|
|
3035
|
-
'20621000087109': 'Canadian English Edition',
|
|
3036
|
-
'20611000087101': 'Canadian Canadian French Edition',
|
|
3037
|
-
'554471000005108': 'Danish Edition',
|
|
3038
|
-
'11000279109': 'Czech Edition',
|
|
3039
|
-
'11000181102': 'Estonian Edition',
|
|
3040
|
-
'11000229106': 'Finnish Edition',
|
|
3041
|
-
'11000274103': 'German Edition',
|
|
3042
|
-
'1121000189102': 'Indian Edition',
|
|
3043
|
-
'827022005': 'IPS Terminology',
|
|
3044
|
-
'11000220105': 'Irish Edition',
|
|
3045
|
-
'11000146104': 'Netherlands Edition',
|
|
3046
|
-
'21000210109': 'New Zealand Edition',
|
|
3047
|
-
'51000202101': 'Norwegian Edition',
|
|
3048
|
-
'11000267109': 'Republic of Korea Edition (South Korea)',
|
|
3049
|
-
'900000001000122104': 'Spanish National Edition',
|
|
3050
|
-
'45991000052106': 'Swedish Edition',
|
|
3051
|
-
'2011000195101': 'Swiss Edition',
|
|
3052
|
-
'83821000000107': 'UK Edition',
|
|
3053
|
-
'999000021000000109': 'UK Clinical Edition',
|
|
3054
|
-
'5631000179106': 'Uruguay Edition',
|
|
3055
|
-
'731000124108': 'US Edition',
|
|
3056
|
-
'21000325107': 'Chilean Edition',
|
|
3057
|
-
'5991000124107': 'US Edition (with ICD-10-CM maps)'
|
|
3058
|
-
};
|
|
3059
|
-
|
|
3060
|
-
return editionMap[edition] || 'Unknown Edition';
|
|
3042
|
+
return editionName(edition) || 'Unknown Edition';
|
|
3061
3043
|
}
|
|
3062
3044
|
|
|
3063
3045
|
function getEditionCode(edition) {
|
|
3064
|
-
|
|
3065
|
-
'900000000000207008': 'Intl',
|
|
3066
|
-
'449081005': 'es',
|
|
3067
|
-
'11000221109': 'AR-es',
|
|
3068
|
-
'32506021000036107': 'AU+',
|
|
3069
|
-
'11000234105': 'AT',
|
|
3070
|
-
'11000172109': 'BE',
|
|
3071
|
-
'20621000087109': 'CA-en',
|
|
3072
|
-
'20611000087101': 'CA-fr',
|
|
3073
|
-
'554471000005108': 'DK',
|
|
3074
|
-
'11000279109': 'CZ',
|
|
3075
|
-
'11000181102': 'ES',
|
|
3076
|
-
'11000229106': 'FI',
|
|
3077
|
-
'11000274103': 'DE',
|
|
3078
|
-
'1121000189102': 'IN',
|
|
3079
|
-
'827022005': 'IPS',
|
|
3080
|
-
'11000220105': 'IE',
|
|
3081
|
-
'11000146104': 'NL',
|
|
3082
|
-
'21000210109': 'NZ',
|
|
3083
|
-
'51000202101': 'NO',
|
|
3084
|
-
'11000267109': 'KR',
|
|
3085
|
-
'900000001000122104': 'ES-es',
|
|
3086
|
-
'45991000052106': 'SW',
|
|
3087
|
-
'2011000195101': 'CH',
|
|
3088
|
-
'83821000000107': 'UK',
|
|
3089
|
-
'999000021000000109': 'UK-Clinical',
|
|
3090
|
-
'5631000179106': 'UR',
|
|
3091
|
-
'731000124108': 'US',
|
|
3092
|
-
'21000325107': 'CL',
|
|
3093
|
-
'5991000124107': 'US+)'
|
|
3094
|
-
};
|
|
3095
|
-
|
|
3096
|
-
return editionMap[edition] || 'Unknown Edition';
|
|
3046
|
+
return editionCode(edition) || 'Unknown Edition';
|
|
3097
3047
|
}
|
|
3098
3048
|
|
|
3099
3049
|
|