fhirsmith 0.18.2 → 1.0.0

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