fhirsmith 0.11.2 → 0.13.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/CHANGELOG.md +377 -2
- package/README.md +1 -1
- package/config-template.json +31 -1
- package/library/languages.js +191 -34
- package/library/path-safety.js +51 -0
- package/library/request-token.js +56 -0
- package/package.json +2 -2
- package/packages/packages.js +17 -118
- package/publisher/publisher.js +208 -30
- package/publisher/validation.js +210 -0
- package/server.js +24 -15
- package/stats-db.js +127 -0
- package/stats.js +172 -6
- package/translations/Messages.properties +5 -1
- package/tx/README.md +21 -0
- package/tx/cs/cs-api.js +30 -1
- package/tx/cs/cs-api.md +1 -1
- package/tx/cs/cs-cs.js +1 -1
- package/tx/cs/cs-icd11.js +805 -0
- package/tx/cs/cs-lang.js +331 -85
- package/tx/cs/cs-loinc.js +45 -4
- package/tx/cs/cs-mimetypes.js +492 -21
- package/tx/cs/cs-snomed.js +141 -58
- package/tx/cs/cs-ucum.js +33 -3
- package/tx/cs/icd11-expressions.js +243 -0
- package/tx/importers/export-icd11-codesystem.js +823 -0
- package/tx/importers/icd11-schema.md +285 -0
- package/tx/importers/import-icd11.js +861 -0
- package/tx/importers/import-loinc.module.js +14 -7
- package/tx/importers/import-sct.module.js +10 -1
- package/tx/library/codesystem.js +21 -27
- package/tx/library/conceptmap.js +39 -0
- package/tx/library/nesting-limit.js +79 -0
- package/tx/library/operation-outcome.js +97 -3
- package/tx/library/parameter-check.js +139 -0
- package/tx/library/valueset.js +16 -20
- package/tx/library.js +23 -1
- package/tx/ocl/cache/cache-utils.cjs +5 -3
- package/tx/ocl/cs-ocl.cjs +73 -17
- package/tx/ocl/jobs/background-queue.cjs +6 -3
- package/tx/ocl/mappers/concept-mapper.cjs +9 -1
- package/tx/ocl/shared/patches.cjs +48 -1
- package/tx/ocl/vs-ocl.cjs +15 -13
- package/tx/operation-context.js +220 -10
- package/tx/params.js +226 -175
- package/tx/sct/expressions.js +677 -15
- package/tx/tx.fhir.org.yml +3 -2
- package/tx/tx.js +72 -92
- package/tx/workers/batch-validate.js +11 -18
- package/tx/workers/batch.js +16 -28
- package/tx/workers/cache-control.js +87 -17
- package/tx/workers/closure.js +3 -8
- package/tx/workers/compare.js +6 -34
- package/tx/workers/expand.js +108 -60
- package/tx/workers/lookup.js +35 -40
- package/tx/workers/read.js +6 -40
- package/tx/workers/search.js +2 -8
- package/tx/workers/subsumes.js +58 -41
- package/tx/workers/translate.js +472 -47
- package/tx/workers/validate.js +83 -78
- package/tx/workers/worker.js +92 -11
- package/tx/xml/xml-base.js +264 -77
- package/tx/xversion/xv-conceptmap.js +81 -0
- package/tx/xversion/xv-parameters.js +8 -0
- package/tx/xversion/xv-valueset.js +39 -1
- package/utilities/merge-audit.py +178 -0
- package/utilities/why-merge-audit.md +39 -0
- package/tx/data/snomed-subset.txt +0 -84
|
@@ -0,0 +1,861 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* import-icd11.js -- build a SQLite database of ICD-11 content from the WHO ICD-API.
|
|
4
|
+
*
|
|
5
|
+
* node import-icd11.js --native <native endpoint> --fhir <fhir endpoint> --dest <file.db>
|
|
6
|
+
*
|
|
7
|
+
* The native API is the content source: it is the only one that can enumerate the
|
|
8
|
+
* classification (GET the linearization root for its top-level children, then
|
|
9
|
+
* `?include=descendant` on each for its whole subtree). The FHIR endpoint is used for
|
|
10
|
+
* release metadata and for an optional sample of $lookup responses kept for
|
|
11
|
+
* cross-checking a provider's output against WHO's own server.
|
|
12
|
+
*
|
|
13
|
+
* Everything with structural meaning -- hierarchy, codes, class kinds, postcoordination
|
|
14
|
+
* axes and their permitted values, cross references -- is stored in real columns and
|
|
15
|
+
* rows. Free text that has no structural consequence is stored in `designation`, and the
|
|
16
|
+
* untouched native JSON for each concept is kept in `concept.blob` as a fallback.
|
|
17
|
+
*
|
|
18
|
+
* The crawl is resumable: re-running against an existing database skips concepts that
|
|
19
|
+
* are already complete, so it can be stopped and restarted, or topped up with an extra
|
|
20
|
+
* language later.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
'use strict';
|
|
24
|
+
|
|
25
|
+
const fs = require('fs');
|
|
26
|
+
const path = require('path');
|
|
27
|
+
const Database = require('better-sqlite3');
|
|
28
|
+
const { Command } = require('commander');
|
|
29
|
+
|
|
30
|
+
const TOOL_VERSION = '1.0.0';
|
|
31
|
+
const WHO = 'http://id.who.int';
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// schema
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
const SCHEMA = `
|
|
38
|
+
PRAGMA journal_mode = WAL;
|
|
39
|
+
PRAGMA synchronous = NORMAL;
|
|
40
|
+
|
|
41
|
+
CREATE TABLE IF NOT EXISTS meta (
|
|
42
|
+
key TEXT PRIMARY KEY,
|
|
43
|
+
value TEXT
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
-- one row per code system served out of this database
|
|
47
|
+
CREATE TABLE IF NOT EXISTS system (
|
|
48
|
+
id INTEGER PRIMARY KEY,
|
|
49
|
+
code TEXT NOT NULL UNIQUE, -- mms | icf | foundation
|
|
50
|
+
url TEXT NOT NULL, -- canonical url, the FHIR CodeSystem.url
|
|
51
|
+
version TEXT NOT NULL, -- release id, eg 2026-01
|
|
52
|
+
title TEXT,
|
|
53
|
+
is_tree INTEGER NOT NULL, -- 1 if every concept has at most one parent
|
|
54
|
+
api_root TEXT NOT NULL, -- native path the crawl started from
|
|
55
|
+
uri_stem TEXT NOT NULL -- versioned uri prefix used by the native API
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
-- one row per concept
|
|
59
|
+
CREATE TABLE IF NOT EXISTS concept (
|
|
60
|
+
id INTEGER PRIMARY KEY,
|
|
61
|
+
system INTEGER NOT NULL REFERENCES system(id),
|
|
62
|
+
entity_id TEXT NOT NULL, -- 257068234, or 1363559646/other for a residual
|
|
63
|
+
uri TEXT NOT NULL, -- unversioned uri; this is what FHIR accepts as a code
|
|
64
|
+
code TEXT, -- the short code, NULL where the entity has none
|
|
65
|
+
class_kind TEXT, -- chapter | block | category | window
|
|
66
|
+
selectable INTEGER NOT NULL, -- 1 if this may be used to code with
|
|
67
|
+
residual TEXT, -- NULL | other | unspecified
|
|
68
|
+
depth INTEGER, -- shortest distance from a root
|
|
69
|
+
child_count INTEGER NOT NULL DEFAULT 0,
|
|
70
|
+
block_id TEXT,
|
|
71
|
+
code_range TEXT,
|
|
72
|
+
source_uri TEXT, -- the Foundation entity this linearization entry came from
|
|
73
|
+
browser_url TEXT,
|
|
74
|
+
lft INTEGER, -- nested set interval; only populated for tree systems
|
|
75
|
+
rgt INTEGER,
|
|
76
|
+
blob TEXT -- the native JSON, primary language
|
|
77
|
+
);
|
|
78
|
+
CREATE UNIQUE INDEX IF NOT EXISTS concept_uri ON concept(system, uri);
|
|
79
|
+
CREATE INDEX IF NOT EXISTS concept_code ON concept(system, code);
|
|
80
|
+
CREATE INDEX IF NOT EXISTS concept_kind ON concept(system, class_kind);
|
|
81
|
+
CREATE INDEX IF NOT EXISTS concept_src ON concept(source_uri);
|
|
82
|
+
CREATE INDEX IF NOT EXISTS concept_nested ON concept(system, lft, rgt);
|
|
83
|
+
|
|
84
|
+
-- parent/child edges. A concept may have several parents in the Foundation.
|
|
85
|
+
CREATE TABLE IF NOT EXISTS concept_parent (
|
|
86
|
+
child INTEGER NOT NULL REFERENCES concept(id),
|
|
87
|
+
parent INTEGER NOT NULL REFERENCES concept(id),
|
|
88
|
+
seq INTEGER NOT NULL,
|
|
89
|
+
PRIMARY KEY (child, parent)
|
|
90
|
+
);
|
|
91
|
+
CREATE INDEX IF NOT EXISTS concept_parent_p ON concept_parent(parent);
|
|
92
|
+
|
|
93
|
+
-- transitive closure, so is-a / descendant-of never needs a recursive query at runtime.
|
|
94
|
+
-- Includes the reflexive (ancestor = descendant, depth 0) row for every concept.
|
|
95
|
+
CREATE TABLE IF NOT EXISTS concept_closure (
|
|
96
|
+
ancestor INTEGER NOT NULL REFERENCES concept(id),
|
|
97
|
+
descendant INTEGER NOT NULL REFERENCES concept(id),
|
|
98
|
+
depth INTEGER NOT NULL,
|
|
99
|
+
PRIMARY KEY (ancestor, descendant)
|
|
100
|
+
);
|
|
101
|
+
CREATE INDEX IF NOT EXISTS concept_closure_d ON concept_closure(descendant);
|
|
102
|
+
|
|
103
|
+
-- all the human-readable text, in every language crawled
|
|
104
|
+
CREATE TABLE IF NOT EXISTS designation (
|
|
105
|
+
concept INTEGER NOT NULL REFERENCES concept(id),
|
|
106
|
+
lang TEXT NOT NULL,
|
|
107
|
+
kind TEXT NOT NULL, -- title | definition | longDefinition | codingNote
|
|
108
|
+
-- | fullySpecifiedName | diagnosticCriteria | indexTerm | inclusion
|
|
109
|
+
value TEXT NOT NULL,
|
|
110
|
+
seq INTEGER NOT NULL
|
|
111
|
+
);
|
|
112
|
+
CREATE INDEX IF NOT EXISTS designation_c ON designation(concept, lang, kind);
|
|
113
|
+
CREATE INDEX IF NOT EXISTS designation_v ON designation(value COLLATE NOCASE);
|
|
114
|
+
|
|
115
|
+
-- pointers from one concept to another that are not hierarchy
|
|
116
|
+
CREATE TABLE IF NOT EXISTS concept_reference (
|
|
117
|
+
concept INTEGER NOT NULL REFERENCES concept(id),
|
|
118
|
+
kind TEXT NOT NULL, -- exclusion | foundationChildElsewhere
|
|
119
|
+
-- | relatedMaternal | relatedPerinatal
|
|
120
|
+
target INTEGER REFERENCES concept(id), -- resolved where possible
|
|
121
|
+
target_uri TEXT,
|
|
122
|
+
foundation_uri TEXT,
|
|
123
|
+
label TEXT,
|
|
124
|
+
lang TEXT,
|
|
125
|
+
seq INTEGER NOT NULL
|
|
126
|
+
);
|
|
127
|
+
CREATE INDEX IF NOT EXISTS concept_reference_c ON concept_reference(concept, kind);
|
|
128
|
+
CREATE INDEX IF NOT EXISTS concept_reference_t ON concept_reference(target);
|
|
129
|
+
|
|
130
|
+
-- the postcoordination axes declared on a stem concept
|
|
131
|
+
CREATE TABLE IF NOT EXISTS pc_scale (
|
|
132
|
+
id INTEGER PRIMARY KEY,
|
|
133
|
+
concept INTEGER NOT NULL REFERENCES concept(id),
|
|
134
|
+
axis TEXT NOT NULL, -- http://id.who.int/icd/schema/infectiousAgent
|
|
135
|
+
axis_name TEXT NOT NULL, -- infectiousAgent
|
|
136
|
+
required INTEGER NOT NULL,
|
|
137
|
+
allow_multiple TEXT, -- AllowAlways | NotAllowed | AllowedExceptFromSameBlock
|
|
138
|
+
value_set_uri TEXT NOT NULL, -- the FHIR ValueSet canonical for this axis
|
|
139
|
+
seq INTEGER NOT NULL
|
|
140
|
+
);
|
|
141
|
+
CREATE UNIQUE INDEX IF NOT EXISTS pc_scale_ca ON pc_scale(concept, axis);
|
|
142
|
+
|
|
143
|
+
-- the roots of each axis. The permitted values are these plus their descendants,
|
|
144
|
+
-- so membership is pc_scale_entity JOIN concept_closure.
|
|
145
|
+
CREATE TABLE IF NOT EXISTS pc_scale_entity (
|
|
146
|
+
scale INTEGER NOT NULL REFERENCES pc_scale(id),
|
|
147
|
+
target INTEGER REFERENCES concept(id),
|
|
148
|
+
target_uri TEXT NOT NULL,
|
|
149
|
+
seq INTEGER NOT NULL,
|
|
150
|
+
PRIMARY KEY (scale, target_uri)
|
|
151
|
+
);
|
|
152
|
+
CREATE INDEX IF NOT EXISTS pc_scale_entity_t ON pc_scale_entity(target);
|
|
153
|
+
|
|
154
|
+
-- a sample of $lookup responses from the FHIR endpoint, for cross-checking
|
|
155
|
+
CREATE TABLE IF NOT EXISTS fhir_sample (
|
|
156
|
+
concept INTEGER PRIMARY KEY REFERENCES concept(id),
|
|
157
|
+
fetched TEXT NOT NULL,
|
|
158
|
+
status INTEGER NOT NULL,
|
|
159
|
+
body TEXT NOT NULL
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
-- crawl bookkeeping, so an interrupted run can pick up where it left off
|
|
163
|
+
CREATE TABLE IF NOT EXISTS crawl_lang (
|
|
164
|
+
system INTEGER NOT NULL REFERENCES system(id),
|
|
165
|
+
concept INTEGER NOT NULL REFERENCES concept(id),
|
|
166
|
+
lang TEXT NOT NULL,
|
|
167
|
+
PRIMARY KEY (system, concept, lang)
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
-- anything that went wrong, kept rather than thrown away
|
|
171
|
+
CREATE TABLE IF NOT EXISTS problem (
|
|
172
|
+
id INTEGER PRIMARY KEY,
|
|
173
|
+
at TEXT NOT NULL,
|
|
174
|
+
system TEXT,
|
|
175
|
+
lang TEXT,
|
|
176
|
+
uri TEXT,
|
|
177
|
+
kind TEXT NOT NULL,
|
|
178
|
+
detail TEXT
|
|
179
|
+
);
|
|
180
|
+
`;
|
|
181
|
+
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
// small helpers
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
const now = () => new Date().toISOString();
|
|
187
|
+
|
|
188
|
+
function stripTrailingSlash(s) {
|
|
189
|
+
return s.replace(/\/+$/, '');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Rewrite a WHO canonical uri onto the endpoint we are actually talking to. */
|
|
193
|
+
function toEndpoint(uri, nativeBase) {
|
|
194
|
+
return uri.startsWith(WHO) ? nativeBase + uri.slice(WHO.length) : uri;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** The unversioned form of a linearization uri -- what the FHIR API uses as a code. */
|
|
198
|
+
function unversion(uri) {
|
|
199
|
+
return uri.replace(/(\/icd\/release\/11)\/[^/]+\/(mms|icf)\b/, '$1/$2');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The tail of an entity uri: '257068234', or '1363559646/other' for a residual.
|
|
204
|
+
* Accepts both the versioned (.../release/11/2026-01/mms/x) and unversioned
|
|
205
|
+
* (.../release/11/mms/x) forms, and Foundation uris (.../icd/entity/x).
|
|
206
|
+
*/
|
|
207
|
+
function entityIdOf(uri) {
|
|
208
|
+
const m = uri.match(/\/icd\/(?:entity|release\/11\/(?:[^/]+\/)?(?:mms|icf))\/(.+)$/);
|
|
209
|
+
return m ? m[1] : uri;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function residualOf(entityId) {
|
|
213
|
+
if (entityId.endsWith('/other')) return 'other';
|
|
214
|
+
if (entityId.endsWith('/unspecified')) return 'unspecified';
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Language-tagged native values come as { '@language': .., '@value': .. }. */
|
|
219
|
+
function textOf(v) {
|
|
220
|
+
if (v == null) return null;
|
|
221
|
+
if (typeof v === 'string') return v;
|
|
222
|
+
return v['@value'] ?? null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function langOf(v, fallback) {
|
|
226
|
+
return (v && typeof v === 'object' && v['@language']) || fallback;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function fmtDuration(ms) {
|
|
230
|
+
const s = Math.round(ms / 1000);
|
|
231
|
+
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60);
|
|
232
|
+
return h ? `${h}h${String(m).padStart(2, '0')}m` : m ? `${m}m${String(s % 60).padStart(2, '0')}s` : `${s}s`;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
// http
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
class Fetcher {
|
|
240
|
+
constructor({ concurrency, retries, timeout, verbose }) {
|
|
241
|
+
this.concurrency = concurrency;
|
|
242
|
+
this.retries = retries;
|
|
243
|
+
this.timeout = timeout;
|
|
244
|
+
this.verbose = verbose;
|
|
245
|
+
this.count = 0;
|
|
246
|
+
this.retried = 0;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async json(url, headers) {
|
|
250
|
+
let wait = 1000;
|
|
251
|
+
for (let attempt = 0; ; attempt++) {
|
|
252
|
+
let res;
|
|
253
|
+
try {
|
|
254
|
+
const ctl = AbortSignal.timeout(this.timeout);
|
|
255
|
+
res = await fetch(url, { headers, signal: ctl });
|
|
256
|
+
} catch (e) {
|
|
257
|
+
if (attempt >= this.retries) return { ok: false, status: 0, error: String(e && e.message || e) };
|
|
258
|
+
this.retried++;
|
|
259
|
+
await new Promise(r => setTimeout(r, wait)); wait = Math.min(wait * 2, 30000);
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
this.count++;
|
|
263
|
+
if (res.status === 429 || res.status >= 500) {
|
|
264
|
+
if (attempt >= this.retries) {
|
|
265
|
+
return { ok: false, status: res.status, error: `HTTP ${res.status}` };
|
|
266
|
+
}
|
|
267
|
+
this.retried++;
|
|
268
|
+
const ra = Number(res.headers.get('retry-after'));
|
|
269
|
+
await new Promise(r => setTimeout(r, Number.isFinite(ra) && ra > 0 ? ra * 1000 : wait));
|
|
270
|
+
wait = Math.min(wait * 2, 30000);
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
let body = null;
|
|
274
|
+
try { body = await res.json(); } catch { /* not json */ }
|
|
275
|
+
if (!res.ok) return { ok: false, status: res.status, body, error: `HTTP ${res.status}` };
|
|
276
|
+
return { ok: true, status: res.status, body };
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Run `worker` over `items` with a fixed number of concurrent slots. */
|
|
281
|
+
async pool(items, worker) {
|
|
282
|
+
let next = 0;
|
|
283
|
+
const runners = Array.from({ length: Math.min(this.concurrency, items.length) }, async () => {
|
|
284
|
+
for (;;) {
|
|
285
|
+
const i = next++;
|
|
286
|
+
if (i >= items.length) return;
|
|
287
|
+
await worker(items[i], i);
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
await Promise.all(runners);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ---------------------------------------------------------------------------
|
|
295
|
+
// system definitions
|
|
296
|
+
// ---------------------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
/** Where each code system's crawl starts, and what it is called in FHIR. */
|
|
299
|
+
function systemDefs(release) {
|
|
300
|
+
return {
|
|
301
|
+
mms: {
|
|
302
|
+
code: 'mms',
|
|
303
|
+
url: 'http://id.who.int/icd/release/11/mms',
|
|
304
|
+
apiRoot: `/icd/release/11/${release}/mms`,
|
|
305
|
+
uriStem: `${WHO}/icd/release/11/${release}/mms`,
|
|
306
|
+
isTree: 1,
|
|
307
|
+
},
|
|
308
|
+
icf: {
|
|
309
|
+
code: 'icf',
|
|
310
|
+
url: 'http://id.who.int/icd/release/11/icf',
|
|
311
|
+
apiRoot: `/icd/release/11/${release}/icf`,
|
|
312
|
+
uriStem: `${WHO}/icd/release/11/${release}/icf`,
|
|
313
|
+
isTree: 1,
|
|
314
|
+
},
|
|
315
|
+
foundation: {
|
|
316
|
+
code: 'foundation',
|
|
317
|
+
url: 'http://id.who.int/icd/entity',
|
|
318
|
+
apiRoot: '/icd/entity',
|
|
319
|
+
uriStem: `${WHO}/icd/entity`,
|
|
320
|
+
isTree: 0,
|
|
321
|
+
},
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ---------------------------------------------------------------------------
|
|
326
|
+
// importer
|
|
327
|
+
// ---------------------------------------------------------------------------
|
|
328
|
+
|
|
329
|
+
class Icd11Importer {
|
|
330
|
+
constructor(opts) {
|
|
331
|
+
this.opts = opts;
|
|
332
|
+
this.native = stripTrailingSlash(opts.native);
|
|
333
|
+
this.fhir = opts.fhir ? stripTrailingSlash(opts.fhir) : null;
|
|
334
|
+
this.fetcher = new Fetcher(opts);
|
|
335
|
+
this.log = (...a) => console.log(...a);
|
|
336
|
+
this.warn = (...a) => console.warn(...a);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
headers(lang) {
|
|
340
|
+
return {
|
|
341
|
+
'Accept': 'application/json',
|
|
342
|
+
'API-Version': 'v2',
|
|
343
|
+
'Accept-Language': lang,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async getEntity(uri, lang, include) {
|
|
348
|
+
const url = toEndpoint(uri, this.native) + (include ? `?include=${include}` : '');
|
|
349
|
+
return this.fetcher.json(url, this.headers(lang));
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
problem(kind, detail, { system, lang, uri } = {}) {
|
|
353
|
+
this.stmt.problem.run(now(), system ?? null, lang ?? null, uri ?? null, kind, detail ?? null);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// -- setup ---------------------------------------------------------------
|
|
357
|
+
|
|
358
|
+
open() {
|
|
359
|
+
const dest = this.opts.dest;
|
|
360
|
+
if (this.opts.overwrite && fs.existsSync(dest)) {
|
|
361
|
+
for (const suffix of ['', '-wal', '-shm']) {
|
|
362
|
+
if (fs.existsSync(dest + suffix)) fs.unlinkSync(dest + suffix);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
fs.mkdirSync(path.dirname(path.resolve(dest)), { recursive: true });
|
|
366
|
+
this.db = new Database(dest);
|
|
367
|
+
this.db.exec(SCHEMA);
|
|
368
|
+
this.prepare();
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
prepare() {
|
|
372
|
+
const db = this.db;
|
|
373
|
+
this.stmt = {
|
|
374
|
+
setMeta: db.prepare(`INSERT INTO meta(key,value) VALUES (?,?)
|
|
375
|
+
ON CONFLICT(key) DO UPDATE SET value=excluded.value`),
|
|
376
|
+
getMeta: db.prepare(`SELECT value FROM meta WHERE key=?`),
|
|
377
|
+
insSystem: db.prepare(`INSERT INTO system(code,url,version,title,is_tree,api_root,uri_stem)
|
|
378
|
+
VALUES (@code,@url,@version,@title,@is_tree,@api_root,@uri_stem)
|
|
379
|
+
ON CONFLICT(code) DO UPDATE SET
|
|
380
|
+
url=excluded.url, version=excluded.version, title=excluded.title,
|
|
381
|
+
is_tree=excluded.is_tree, api_root=excluded.api_root,
|
|
382
|
+
uri_stem=excluded.uri_stem
|
|
383
|
+
RETURNING id`),
|
|
384
|
+
insConcept: db.prepare(`INSERT INTO concept
|
|
385
|
+
(system,entity_id,uri,code,class_kind,selectable,residual,child_count,
|
|
386
|
+
block_id,code_range,source_uri,browser_url,blob)
|
|
387
|
+
VALUES (@system,@entity_id,@uri,@code,@class_kind,@selectable,@residual,@child_count,
|
|
388
|
+
@block_id,@code_range,@source_uri,@browser_url,@blob)
|
|
389
|
+
ON CONFLICT(system,uri) DO UPDATE SET
|
|
390
|
+
entity_id=excluded.entity_id, code=excluded.code, class_kind=excluded.class_kind,
|
|
391
|
+
selectable=excluded.selectable, residual=excluded.residual,
|
|
392
|
+
child_count=excluded.child_count, block_id=excluded.block_id,
|
|
393
|
+
code_range=excluded.code_range, source_uri=excluded.source_uri,
|
|
394
|
+
browser_url=excluded.browser_url, blob=excluded.blob
|
|
395
|
+
RETURNING id`),
|
|
396
|
+
conceptId: db.prepare(`SELECT id FROM concept WHERE system=? AND uri=?`),
|
|
397
|
+
delParents: db.prepare(`DELETE FROM concept_parent WHERE child=?`),
|
|
398
|
+
insParent: db.prepare(`INSERT OR IGNORE INTO concept_parent(child,parent,seq) VALUES (?,?,?)`),
|
|
399
|
+
delDesig: db.prepare(`DELETE FROM designation WHERE concept=? AND lang=?`),
|
|
400
|
+
insDesig: db.prepare(`INSERT INTO designation(concept,lang,kind,value,seq) VALUES (?,?,?,?,?)`),
|
|
401
|
+
delRefs: db.prepare(`DELETE FROM concept_reference WHERE concept=? AND lang=?`),
|
|
402
|
+
insRef: db.prepare(`INSERT INTO concept_reference
|
|
403
|
+
(concept,kind,target,target_uri,foundation_uri,label,lang,seq) VALUES (?,?,?,?,?,?,?,?)`),
|
|
404
|
+
delScales: db.prepare(`DELETE FROM pc_scale_entity WHERE scale IN (SELECT id FROM pc_scale WHERE concept=?)`),
|
|
405
|
+
delScales2: db.prepare(`DELETE FROM pc_scale WHERE concept=?`),
|
|
406
|
+
insScale: db.prepare(`INSERT INTO pc_scale
|
|
407
|
+
(concept,axis,axis_name,required,allow_multiple,value_set_uri,seq)
|
|
408
|
+
VALUES (?,?,?,?,?,?,?) RETURNING id`),
|
|
409
|
+
insScaleEntity: db.prepare(`INSERT OR IGNORE INTO pc_scale_entity(scale,target,target_uri,seq)
|
|
410
|
+
VALUES (?,?,?,?)`),
|
|
411
|
+
markLang: db.prepare(`INSERT OR IGNORE INTO crawl_lang(system,concept,lang) VALUES (?,?,?)`),
|
|
412
|
+
doneLangs: db.prepare(`SELECT c.uri AS uri FROM crawl_lang cl JOIN concept c ON c.id=cl.concept
|
|
413
|
+
WHERE cl.system=? AND cl.lang=?`),
|
|
414
|
+
insFhir: db.prepare(`INSERT INTO fhir_sample(concept,fetched,status,body) VALUES (?,?,?,?)
|
|
415
|
+
ON CONFLICT(concept) DO UPDATE SET
|
|
416
|
+
fetched=excluded.fetched, status=excluded.status, body=excluded.body`),
|
|
417
|
+
problem: db.prepare(`INSERT INTO problem(at,system,lang,uri,kind,detail) VALUES (?,?,?,?,?,?)`),
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// -- enumeration ---------------------------------------------------------
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* The complete id list for a system. GET the root for its top-level children, then
|
|
425
|
+
* ?include=descendant on each child for its entire subtree. This is the only reliable
|
|
426
|
+
* enumeration the API offers, and it is 1 + n calls rather than one per concept.
|
|
427
|
+
*/
|
|
428
|
+
async enumerate(def, lang) {
|
|
429
|
+
const rootRes = await this.getEntity(WHO + def.apiRoot, lang);
|
|
430
|
+
if (!rootRes.ok) throw new Error(`cannot read ${def.apiRoot}: ${rootRes.error}`);
|
|
431
|
+
const root = rootRes.body;
|
|
432
|
+
const tops = root.child || [];
|
|
433
|
+
this.log(` root: ${textOf(root.title)} -- ${tops.length} top-level children`);
|
|
434
|
+
|
|
435
|
+
const all = new Set(tops);
|
|
436
|
+
let seq = 0;
|
|
437
|
+
await this.fetcher.pool(tops, async (uri) => {
|
|
438
|
+
const r = await this.getEntity(uri, lang, 'descendant');
|
|
439
|
+
if (!r.ok) {
|
|
440
|
+
this.problem('enumerate-failed', r.error, { system: def.code, lang, uri });
|
|
441
|
+
this.warn(` ! could not enumerate ${uri}: ${r.error}`);
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
const ds = r.body.descendant || [];
|
|
445
|
+
for (const d of ds) all.add(d);
|
|
446
|
+
seq++;
|
|
447
|
+
this.log(` [${String(seq).padStart(2)}/${tops.length}] ${String(ds.length).padStart(6)} descendants ${textOf(r.body.title) || ''}`.slice(0, 110));
|
|
448
|
+
});
|
|
449
|
+
return { root, uris: [...all] };
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// -- one concept ---------------------------------------------------------
|
|
453
|
+
|
|
454
|
+
/** Write everything a single native entity document tells us. */
|
|
455
|
+
storeConcept(systemId, systemCode, doc, lang, isPrimary) {
|
|
456
|
+
const uriVersioned = doc['@id'];
|
|
457
|
+
const uri = unversion(uriVersioned);
|
|
458
|
+
const entityId = entityIdOf(uri);
|
|
459
|
+
const code = (doc.code ?? '').trim() || null;
|
|
460
|
+
const classKind = doc.classKind ?? null;
|
|
461
|
+
const children = doc.child || [];
|
|
462
|
+
|
|
463
|
+
let conceptId;
|
|
464
|
+
if (isPrimary) {
|
|
465
|
+
conceptId = this.stmt.insConcept.get({
|
|
466
|
+
system: systemId,
|
|
467
|
+
entity_id: entityId,
|
|
468
|
+
uri,
|
|
469
|
+
code,
|
|
470
|
+
class_kind: classKind,
|
|
471
|
+
selectable: code && classKind === 'category' ? 1 : 0,
|
|
472
|
+
residual: residualOf(entityId),
|
|
473
|
+
child_count: children.length,
|
|
474
|
+
block_id: doc.blockId ?? null,
|
|
475
|
+
code_range: doc.codeRange ?? null,
|
|
476
|
+
source_uri: doc.source ?? null,
|
|
477
|
+
browser_url: doc.browserUrl ?? null,
|
|
478
|
+
blob: JSON.stringify(doc),
|
|
479
|
+
}).id;
|
|
480
|
+
|
|
481
|
+
// hierarchy -- parents are resolved to ids in a second pass, so record the uris
|
|
482
|
+
this.pendingParents.push([conceptId, (doc.parent || []).map(unversion)]);
|
|
483
|
+
|
|
484
|
+
// postcoordination
|
|
485
|
+
this.stmt.delScales.run(conceptId);
|
|
486
|
+
this.stmt.delScales2.run(conceptId);
|
|
487
|
+
(doc.postcoordinationScale || []).forEach((sc, i) => {
|
|
488
|
+
const axis = sc.axisName || '';
|
|
489
|
+
const axisName = axis.split('/').pop();
|
|
490
|
+
const scaleId = this.stmt.insScale.get(
|
|
491
|
+
conceptId,
|
|
492
|
+
axis,
|
|
493
|
+
axisName,
|
|
494
|
+
String(sc.requiredPostcoordination) === 'true' ? 1 : 0,
|
|
495
|
+
sc.allowMultipleValues ?? null,
|
|
496
|
+
unversion(sc['@id'] || `${uri}/postcoordinationScale/${axisName}`),
|
|
497
|
+
i
|
|
498
|
+
).id;
|
|
499
|
+
(sc.scaleEntity || []).forEach((se, j) => {
|
|
500
|
+
this.pendingScaleEntities.push([scaleId, unversion(se), j]);
|
|
501
|
+
});
|
|
502
|
+
});
|
|
503
|
+
} else {
|
|
504
|
+
const row = this.stmt.conceptId.get(systemId, uri);
|
|
505
|
+
if (!row) return null; // language pass found something the primary pass did not
|
|
506
|
+
conceptId = row.id;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// text, per language
|
|
510
|
+
this.stmt.delDesig.run(conceptId, lang);
|
|
511
|
+
let seq = 0;
|
|
512
|
+
const put = (kind, value) => {
|
|
513
|
+
if (value != null && String(value).length) {
|
|
514
|
+
this.stmt.insDesig.run(conceptId, lang, kind, String(value), seq++);
|
|
515
|
+
}
|
|
516
|
+
};
|
|
517
|
+
put('title', textOf(doc.title));
|
|
518
|
+
put('definition', textOf(doc.definition));
|
|
519
|
+
put('longDefinition', textOf(doc.longDefinition));
|
|
520
|
+
put('fullySpecifiedName', textOf(doc.fullySpecifiedName));
|
|
521
|
+
put('codingNote', textOf(doc.codingNote));
|
|
522
|
+
put('diagnosticCriteria', textOf(doc.diagnosticCriteria));
|
|
523
|
+
for (const t of doc.indexTerm || []) put('indexTerm', textOf(t.label));
|
|
524
|
+
for (const t of doc.inclusion || []) put('inclusion', textOf(t.label));
|
|
525
|
+
|
|
526
|
+
// cross references, per language (the labels are translated)
|
|
527
|
+
this.stmt.delRefs.run(conceptId, lang);
|
|
528
|
+
const ref = (kind, list, i0 = 0) => {
|
|
529
|
+
(list || []).forEach((r, i) => {
|
|
530
|
+
const isObj = r && typeof r === 'object';
|
|
531
|
+
const targetUri = isObj ? (r.linearizationReference || r.foundationReference || null) : r;
|
|
532
|
+
this.stmt.insRef.run(
|
|
533
|
+
conceptId, kind, null,
|
|
534
|
+
targetUri ? unversion(targetUri) : null,
|
|
535
|
+
isObj ? (r.foundationReference ?? null) : null,
|
|
536
|
+
isObj ? textOf(r.label) : null,
|
|
537
|
+
lang, i0 + i
|
|
538
|
+
);
|
|
539
|
+
});
|
|
540
|
+
};
|
|
541
|
+
ref('exclusion', doc.exclusion);
|
|
542
|
+
ref('foundationChildElsewhere', doc.foundationChildElsewhere);
|
|
543
|
+
ref('relatedMaternal', doc.relatedEntitiesInMaternalChapter);
|
|
544
|
+
ref('relatedPerinatal', doc.relatedEntitiesInPerinatalChapter);
|
|
545
|
+
|
|
546
|
+
this.stmt.markLang.run(systemId, conceptId, lang);
|
|
547
|
+
return conceptId;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// -- the crawl ------------------------------------------------------------
|
|
551
|
+
|
|
552
|
+
async crawlSystem(def, languages) {
|
|
553
|
+
this.log(`\n=== ${def.code} (${def.url})`);
|
|
554
|
+
const primaryLang = languages[0];
|
|
555
|
+
|
|
556
|
+
const { root, uris } = await this.enumerate(def, primaryLang);
|
|
557
|
+
const release = root.releaseId || this.release;
|
|
558
|
+
|
|
559
|
+
const sysRow = this.stmt.insSystem.get({
|
|
560
|
+
code: def.code, url: def.url, version: release,
|
|
561
|
+
title: textOf(root.title), is_tree: def.isTree,
|
|
562
|
+
api_root: def.apiRoot, uri_stem: def.uriStem,
|
|
563
|
+
});
|
|
564
|
+
const systemId = sysRow.id;
|
|
565
|
+
this.log(` ${uris.length} entities to fetch, languages: ${languages.join(', ')}`);
|
|
566
|
+
|
|
567
|
+
for (const lang of languages) {
|
|
568
|
+
const isPrimary = lang === primaryLang;
|
|
569
|
+
const done = new Set(this.stmt.doneLangs.all(systemId, lang).map(r => r.uri));
|
|
570
|
+
const todo = uris.filter(u => !done.has(unversion(u)));
|
|
571
|
+
if (!todo.length) { this.log(` [${lang}] already complete`); continue; }
|
|
572
|
+
this.log(` [${lang}] ${todo.length} to fetch${done.size ? ` (${done.size} already done)` : ''}`);
|
|
573
|
+
|
|
574
|
+
this.pendingParents = [];
|
|
575
|
+
this.pendingScaleEntities = [];
|
|
576
|
+
|
|
577
|
+
const started = Date.now();
|
|
578
|
+
let done_ = 0, failed = 0;
|
|
579
|
+
const batch = [];
|
|
580
|
+
const flush = this.db.transaction((docs) => {
|
|
581
|
+
for (const d of docs) this.storeConcept(systemId, def.code, d, lang, isPrimary);
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
await this.fetcher.pool(todo, async (uri) => {
|
|
585
|
+
const r = await this.getEntity(uri, lang, 'diagnosticCriteria');
|
|
586
|
+
if (!r.ok) {
|
|
587
|
+
failed++;
|
|
588
|
+
this.problem('fetch-failed', r.error, { system: def.code, lang, uri });
|
|
589
|
+
} else {
|
|
590
|
+
batch.push(r.body);
|
|
591
|
+
if (batch.length >= this.opts.batch) flush(batch.splice(0, batch.length));
|
|
592
|
+
}
|
|
593
|
+
if (++done_ % this.opts.progress === 0) {
|
|
594
|
+
const el = Date.now() - started;
|
|
595
|
+
const rate = done_ / (el / 1000);
|
|
596
|
+
const eta = (todo.length - done_) / Math.max(rate, 0.01) * 1000;
|
|
597
|
+
this.log(` ${done_}/${todo.length} ${rate.toFixed(1)}/s eta ${fmtDuration(eta)}${failed ? ` ${failed} failed` : ''}`);
|
|
598
|
+
}
|
|
599
|
+
});
|
|
600
|
+
if (batch.length) flush(batch);
|
|
601
|
+
|
|
602
|
+
if (isPrimary) this.resolveLinks(systemId, def);
|
|
603
|
+
this.log(` [${lang}] done in ${fmtDuration(Date.now() - started)}${failed ? `, ${failed} failed (see the problem table)` : ''}`);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
if (def.isTree) this.buildNestedSet(systemId);
|
|
607
|
+
this.buildClosure(systemId);
|
|
608
|
+
this.checkIntegrity(systemId, def);
|
|
609
|
+
return systemId;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Turn the parent and scale-entity uris collected during the crawl into real ids.
|
|
614
|
+
* `rootUri` is the linearization root, which the top-level concepts name as their
|
|
615
|
+
* parent but which is not itself a concept -- that is expected, not a problem.
|
|
616
|
+
*/
|
|
617
|
+
resolveLinks(systemId, def) {
|
|
618
|
+
const lookup = this.stmt.conceptId;
|
|
619
|
+
const rootUri = unversion(WHO + def.apiRoot);
|
|
620
|
+
const run = this.db.transaction(() => {
|
|
621
|
+
for (const [childId, parentUris] of this.pendingParents) {
|
|
622
|
+
this.stmt.delParents.run(childId);
|
|
623
|
+
parentUris.forEach((pu, i) => {
|
|
624
|
+
const p = lookup.get(systemId, pu);
|
|
625
|
+
if (p) this.stmt.insParent.run(childId, p.id, i);
|
|
626
|
+
else if (pu !== rootUri) this.problem('unresolved-parent', pu, { system: def.code });
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
for (const [scaleId, uri, seq] of this.pendingScaleEntities) {
|
|
630
|
+
const t = lookup.get(systemId, uri);
|
|
631
|
+
this.stmt.insScaleEntity.run(scaleId, t ? t.id : null, uri, seq);
|
|
632
|
+
}
|
|
633
|
+
// resolve cross references now that every concept exists
|
|
634
|
+
this.db.prepare(`
|
|
635
|
+
UPDATE concept_reference
|
|
636
|
+
SET target = (SELECT c.id FROM concept c
|
|
637
|
+
WHERE c.system = ? AND c.uri = concept_reference.target_uri)
|
|
638
|
+
WHERE target IS NULL AND target_uri IS NOT NULL
|
|
639
|
+
AND concept IN (SELECT id FROM concept WHERE system = ?)`).run(systemId, systemId);
|
|
640
|
+
});
|
|
641
|
+
run();
|
|
642
|
+
this.pendingParents = [];
|
|
643
|
+
this.pendingScaleEntities = [];
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/** Transitive closure, built level by level so memory stays bounded. */
|
|
647
|
+
buildClosure(systemId) {
|
|
648
|
+
const t0 = Date.now();
|
|
649
|
+
this.db.prepare(`DELETE FROM concept_closure WHERE ancestor IN (SELECT id FROM concept WHERE system=?)`).run(systemId);
|
|
650
|
+
this.db.prepare(`INSERT INTO concept_closure(ancestor,descendant,depth)
|
|
651
|
+
SELECT id,id,0 FROM concept WHERE system=?`).run(systemId);
|
|
652
|
+
const step = this.db.prepare(`
|
|
653
|
+
INSERT OR IGNORE INTO concept_closure(ancestor,descendant,depth)
|
|
654
|
+
SELECT cl.ancestor, cp.child, ? FROM concept_closure cl
|
|
655
|
+
JOIN concept_parent cp ON cp.parent = cl.descendant
|
|
656
|
+
WHERE cl.depth = ?`);
|
|
657
|
+
for (let d = 0; d < 64; d++) {
|
|
658
|
+
const info = step.run(d + 1, d);
|
|
659
|
+
if (info.changes === 0) break;
|
|
660
|
+
}
|
|
661
|
+
this.db.prepare(`
|
|
662
|
+
UPDATE concept SET depth = (
|
|
663
|
+
SELECT MIN(cl.depth) FROM concept_closure cl
|
|
664
|
+
JOIN concept r ON r.id = cl.ancestor
|
|
665
|
+
WHERE cl.descendant = concept.id
|
|
666
|
+
AND NOT EXISTS (SELECT 1 FROM concept_parent WHERE child = r.id))
|
|
667
|
+
WHERE system = ?`).run(systemId);
|
|
668
|
+
const n = this.db.prepare(`SELECT COUNT(*) n FROM concept_closure cl
|
|
669
|
+
JOIN concept c ON c.id=cl.ancestor WHERE c.system=?`).get(systemId).n;
|
|
670
|
+
this.log(` closure: ${n} rows in ${fmtDuration(Date.now() - t0)}`);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Nested-set intervals, so "all descendants of X" is a single range scan. Only valid
|
|
675
|
+
* where the hierarchy really is a tree; the Foundation is skipped.
|
|
676
|
+
*/
|
|
677
|
+
buildNestedSet(systemId) {
|
|
678
|
+
const kids = new Map();
|
|
679
|
+
for (const r of this.db.prepare(`
|
|
680
|
+
SELECT cp.parent p, cp.child c FROM concept_parent cp
|
|
681
|
+
JOIN concept x ON x.id = cp.child WHERE x.system = ? ORDER BY cp.seq`).all(systemId)) {
|
|
682
|
+
if (!kids.has(r.p)) kids.set(r.p, []);
|
|
683
|
+
kids.get(r.p).push(r.c);
|
|
684
|
+
}
|
|
685
|
+
const roots = this.db.prepare(`
|
|
686
|
+
SELECT id FROM concept WHERE system = ?
|
|
687
|
+
AND NOT EXISTS (SELECT 1 FROM concept_parent WHERE child = concept.id)
|
|
688
|
+
ORDER BY id`).all(systemId).map(r => r.id);
|
|
689
|
+
|
|
690
|
+
const out = [];
|
|
691
|
+
let n = 0;
|
|
692
|
+
for (const root of roots) {
|
|
693
|
+
// iterative DFS so a deep tree cannot blow the stack
|
|
694
|
+
const stack = [[root, false]];
|
|
695
|
+
while (stack.length) {
|
|
696
|
+
const frame = stack.pop();
|
|
697
|
+
const [id, exiting] = frame;
|
|
698
|
+
if (exiting) { out.push([id, null, ++n]); continue; }
|
|
699
|
+
out.push([id, ++n, null]);
|
|
700
|
+
stack.push([id, true]);
|
|
701
|
+
const cs = kids.get(id) || [];
|
|
702
|
+
for (let i = cs.length - 1; i >= 0; i--) stack.push([cs[i], false]);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
const lft = new Map(), rgt = new Map();
|
|
706
|
+
for (const [id, l, r] of out) {
|
|
707
|
+
if (l != null) lft.set(id, l); else rgt.set(id, r);
|
|
708
|
+
}
|
|
709
|
+
const upd = this.db.prepare(`UPDATE concept SET lft=?, rgt=? WHERE id=?`);
|
|
710
|
+
this.db.transaction(() => {
|
|
711
|
+
for (const [id, l] of lft) upd.run(l, rgt.get(id) ?? l, id);
|
|
712
|
+
})();
|
|
713
|
+
this.log(` nested set: ${lft.size} concepts numbered`);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
checkIntegrity(systemId, def) {
|
|
717
|
+
const q = (sql, ...a) => this.db.prepare(sql).get(systemId, ...a);
|
|
718
|
+
const total = q(`SELECT COUNT(*) n FROM concept WHERE system=?`).n;
|
|
719
|
+
const noCode = q(`SELECT COUNT(*) n FROM concept WHERE system=? AND code IS NULL`).n;
|
|
720
|
+
const sel = q(`SELECT COUNT(*) n FROM concept WHERE system=? AND selectable=1`).n;
|
|
721
|
+
const orphan = q(`SELECT COUNT(*) n FROM concept WHERE system=?
|
|
722
|
+
AND NOT EXISTS (SELECT 1 FROM concept_parent WHERE child=concept.id)`).n;
|
|
723
|
+
const multi = q(`SELECT COUNT(*) n FROM (SELECT child FROM concept_parent cp
|
|
724
|
+
JOIN concept c ON c.id=cp.child WHERE c.system=?
|
|
725
|
+
GROUP BY child HAVING COUNT(*)>1)`).n;
|
|
726
|
+
const scales = q(`SELECT COUNT(*) n FROM pc_scale ps JOIN concept c ON c.id=ps.concept WHERE c.system=?`).n;
|
|
727
|
+
const unres = q(`SELECT COUNT(*) n FROM pc_scale_entity pse
|
|
728
|
+
JOIN pc_scale ps ON ps.id=pse.scale JOIN concept c ON c.id=ps.concept
|
|
729
|
+
WHERE c.system=? AND pse.target IS NULL`).n;
|
|
730
|
+
this.log(` ${total} concepts, ${sel} selectable, ${noCode} without a short code`);
|
|
731
|
+
this.log(` ${orphan} roots, ${multi} with more than one parent, ${scales} postcoordination axes` +
|
|
732
|
+
(unres ? `, ${unres} unresolved axis values` : ''));
|
|
733
|
+
if (def.isTree && multi) {
|
|
734
|
+
this.problem('tree-assumption-broken', `${multi} concepts have multiple parents`, { system: def.code });
|
|
735
|
+
this.warn(` ! ${def.code} was assumed to be a tree but ${multi} concepts have several parents; nested set values are unreliable`);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// -- the FHIR sample ------------------------------------------------------
|
|
740
|
+
|
|
741
|
+
async fhirSample(systemId, def, count) {
|
|
742
|
+
if (!this.fhir || !count) return;
|
|
743
|
+
const rows = this.db.prepare(`
|
|
744
|
+
SELECT id, uri, code FROM concept WHERE system=? ORDER BY RANDOM() LIMIT ?`).all(systemId, count);
|
|
745
|
+
this.log(` fetching ${rows.length} $lookup responses for cross-checking`);
|
|
746
|
+
let ok = 0;
|
|
747
|
+
await this.fetcher.pool(rows, async (row) => {
|
|
748
|
+
const url = `${this.fhir}/CodeSystem/$lookup?system=${encodeURIComponent(def.url)}`
|
|
749
|
+
+ `&code=${encodeURIComponent(row.uri)}`;
|
|
750
|
+
const r = await this.fetcher.json(url, { Accept: 'application/fhir+json' });
|
|
751
|
+
this.stmt.insFhir.run(row.id, now(), r.status, JSON.stringify(r.body ?? { error: r.error }));
|
|
752
|
+
if (r.ok) ok++;
|
|
753
|
+
});
|
|
754
|
+
this.log(` ${ok}/${rows.length} returned 2xx`);
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// -- driver ---------------------------------------------------------------
|
|
758
|
+
|
|
759
|
+
async run() {
|
|
760
|
+
const t0 = Date.now();
|
|
761
|
+
this.open();
|
|
762
|
+
|
|
763
|
+
// release + languages come from the Foundation root, which names both
|
|
764
|
+
const rootRes = await this.getEntity(`${WHO}/icd/entity`, 'en');
|
|
765
|
+
if (!rootRes.ok) throw new Error(`cannot reach the native API at ${this.native}: ${rootRes.error}`);
|
|
766
|
+
this.release = this.opts.release || rootRes.body.releaseId;
|
|
767
|
+
const advertised = rootRes.body.availableLanguages || ['en'];
|
|
768
|
+
const languages = this.opts.languages === 'all'
|
|
769
|
+
? advertised
|
|
770
|
+
: this.opts.languages.split(',').map(s => s.trim()).filter(Boolean);
|
|
771
|
+
const unknown = languages.filter(l => !advertised.includes(l));
|
|
772
|
+
if (unknown.length) this.warn(`! the API does not advertise ${unknown.join(', ')} -- it will fall back to English for those`);
|
|
773
|
+
|
|
774
|
+
this.log(`ICD-11 import`);
|
|
775
|
+
this.log(` native : ${this.native}`);
|
|
776
|
+
this.log(` fhir : ${this.fhir || '(none)'}`);
|
|
777
|
+
this.log(` database : ${this.opts.dest}`);
|
|
778
|
+
this.log(` release : ${this.release} languages: ${languages.join(', ')}`);
|
|
779
|
+
|
|
780
|
+
for (const [k, v] of Object.entries({
|
|
781
|
+
toolVersion: TOOL_VERSION,
|
|
782
|
+
importStarted: now(),
|
|
783
|
+
nativeEndpoint: this.native,
|
|
784
|
+
fhirEndpoint: this.fhir || '',
|
|
785
|
+
release: this.release,
|
|
786
|
+
languages: languages.join(','),
|
|
787
|
+
availableLanguages: advertised.join(','),
|
|
788
|
+
})) this.stmt.setMeta.run(k, v);
|
|
789
|
+
|
|
790
|
+
if (this.fhir) {
|
|
791
|
+
const caps = await this.fetcher.json(`${this.fhir}/metadata?mode=terminology`,
|
|
792
|
+
{ Accept: 'application/fhir+json' });
|
|
793
|
+
if (caps.ok) {
|
|
794
|
+
this.stmt.setMeta.run('fhirTerminologyCapabilities', JSON.stringify(caps.body));
|
|
795
|
+
this.stmt.setMeta.run('fhirSoftware',
|
|
796
|
+
`${caps.body?.software?.name ?? '?'} ${caps.body?.software?.version ?? ''}`.trim());
|
|
797
|
+
} else {
|
|
798
|
+
this.warn(`! could not read TerminologyCapabilities from ${this.fhir}: ${caps.error}`);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
const defs = systemDefs(this.release);
|
|
803
|
+
const wanted = this.opts.systems.split(',').map(s => s.trim()).filter(Boolean);
|
|
804
|
+
for (const name of wanted) {
|
|
805
|
+
const def = defs[name];
|
|
806
|
+
if (!def) throw new Error(`unknown system '${name}' -- expected one of ${Object.keys(defs).join(', ')}`);
|
|
807
|
+
const systemId = await this.crawlSystem(def, languages);
|
|
808
|
+
await this.fhirSample(systemId, def, this.opts.fhirSample);
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
this.stmt.setMeta.run('importFinished', now());
|
|
812
|
+
this.stmt.setMeta.run('httpRequests', String(this.fetcher.count));
|
|
813
|
+
this.log(`\noptimising...`);
|
|
814
|
+
this.db.exec('PRAGMA optimize; VACUUM; ANALYZE;');
|
|
815
|
+
|
|
816
|
+
const probs = this.db.prepare(`SELECT kind, COUNT(*) n FROM problem GROUP BY kind`).all();
|
|
817
|
+
this.log(`\ndone in ${fmtDuration(Date.now() - t0)}, ${this.fetcher.count} requests` +
|
|
818
|
+
(this.fetcher.retried ? ` (${this.fetcher.retried} retried)` : ''));
|
|
819
|
+
for (const p of probs) this.log(` problem: ${p.kind} x${p.n}`);
|
|
820
|
+
const size = fs.statSync(this.opts.dest).size;
|
|
821
|
+
this.log(` ${this.opts.dest} ${(size / 1024 / 1024).toFixed(1)} MB`);
|
|
822
|
+
this.db.close();
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// ---------------------------------------------------------------------------
|
|
827
|
+
// cli
|
|
828
|
+
// ---------------------------------------------------------------------------
|
|
829
|
+
|
|
830
|
+
function main(argv) {
|
|
831
|
+
const program = new Command();
|
|
832
|
+
program
|
|
833
|
+
.name('import-icd11')
|
|
834
|
+
.description('Build a SQLite database of ICD-11 content from the WHO ICD-API')
|
|
835
|
+
.requiredOption('-n, --native <url>', 'native ICD-API endpoint, eg https://id.who.int')
|
|
836
|
+
.option('-f, --fhir <url>', 'FHIR endpoint, used for release metadata and the $lookup sample')
|
|
837
|
+
.requiredOption('-d, --dest <file>', 'destination SQLite database')
|
|
838
|
+
.option('-s, --systems <list>', 'which code systems to import', 'mms,icf,foundation')
|
|
839
|
+
.option('-l, --languages <list>', "comma separated, or 'all' for every language the API advertises", 'all')
|
|
840
|
+
.option('-r, --release <id>', 'release to import (default: whatever the API says is current)')
|
|
841
|
+
.option('-c, --concurrency <n>', 'concurrent requests', v => parseInt(v, 10), 16)
|
|
842
|
+
.option('--fhir-sample <n>', 'how many $lookup responses to keep per system', v => parseInt(v, 10), 500)
|
|
843
|
+
.option('--batch <n>', 'concepts per database transaction', v => parseInt(v, 10), 200)
|
|
844
|
+
.option('--progress <n>', 'log progress every n concepts', v => parseInt(v, 10), 1000)
|
|
845
|
+
.option('--retries <n>', 'retries per request', v => parseInt(v, 10), 4)
|
|
846
|
+
.option('--timeout <ms>', 'request timeout', v => parseInt(v, 10), 90000)
|
|
847
|
+
.option('--overwrite', 'delete any existing database first', false)
|
|
848
|
+
.option('--verbose', 'more logging', false);
|
|
849
|
+
|
|
850
|
+
program.parse(argv);
|
|
851
|
+
return new Icd11Importer(program.opts()).run();
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
if (require.main === module) {
|
|
855
|
+
main(process.argv).catch(e => {
|
|
856
|
+
console.error(`\nimport failed: ${e && e.stack || e}`);
|
|
857
|
+
process.exit(1);
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
module.exports = { Icd11Importer, SCHEMA, unversion, entityIdOf, main };
|