fhirsmith 0.12.0 → 0.13.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.
Files changed (57) hide show
  1. package/CHANGELOG.md +323 -4
  2. package/README.md +1 -1
  3. package/config-template.json +13 -0
  4. package/library/languages.js +191 -34
  5. package/library/path-safety.js +51 -0
  6. package/library/request-token.js +56 -0
  7. package/package.json +2 -2
  8. package/packages/packages.js +17 -118
  9. package/publisher/publisher.js +60 -30
  10. package/publisher/validation.js +15 -3
  11. package/server.js +12 -3
  12. package/translations/Messages.properties +1 -0
  13. package/tx/README.md +21 -0
  14. package/tx/cs/cs-api.js +30 -1
  15. package/tx/cs/cs-cs.js +14 -1
  16. package/tx/cs/cs-icd11.js +844 -0
  17. package/tx/cs/cs-lang.js +331 -85
  18. package/tx/cs/cs-loinc.js +45 -4
  19. package/tx/cs/cs-mimetypes.js +492 -21
  20. package/tx/cs/cs-snomed.js +38 -4
  21. package/tx/cs/cs-ucum.js +33 -3
  22. package/tx/cs/icd11-expressions.js +243 -0
  23. package/tx/importers/export-icd11-codesystem.js +823 -0
  24. package/tx/importers/icd11-schema.md +285 -0
  25. package/tx/importers/import-icd11.js +861 -0
  26. package/tx/importers/import-loinc.module.js +14 -7
  27. package/tx/importers/import-sct.module.js +10 -1
  28. package/tx/library/codesystem.js +21 -27
  29. package/tx/library/nesting-limit.js +79 -0
  30. package/tx/library/operation-outcome.js +97 -3
  31. package/tx/library/valueset.js +16 -20
  32. package/tx/library.js +23 -1
  33. package/tx/ocl/cache/cache-utils.cjs +5 -3
  34. package/tx/ocl/cs-ocl.cjs +73 -17
  35. package/tx/ocl/jobs/background-queue.cjs +6 -3
  36. package/tx/ocl/mappers/concept-mapper.cjs +9 -1
  37. package/tx/ocl/shared/patches.cjs +48 -1
  38. package/tx/ocl/vs-ocl.cjs +15 -13
  39. package/tx/provider.js +1 -1
  40. package/tx/sct/expressions.js +194 -17
  41. package/tx/tx.fhir.org.yml +2 -1
  42. package/tx/tx.js +15 -42
  43. package/tx/workers/batch-validate.js +6 -16
  44. package/tx/workers/batch.js +16 -28
  45. package/tx/workers/cache-control.js +6 -12
  46. package/tx/workers/closure.js +3 -8
  47. package/tx/workers/compare.js +6 -34
  48. package/tx/workers/expand.js +36 -45
  49. package/tx/workers/lookup.js +21 -39
  50. package/tx/workers/read.js +6 -40
  51. package/tx/workers/search.js +2 -8
  52. package/tx/workers/subsumes.js +58 -41
  53. package/tx/workers/translate.js +11 -17
  54. package/tx/workers/validate.js +76 -77
  55. package/tx/workers/worker.js +70 -1
  56. package/tx/xml/xml-base.js +243 -76
  57. package/tx/xversion/xv-valueset.js +39 -1
@@ -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 };