fhirsmith 0.18.1 → 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 (214) hide show
  1. package/ACCESSIBILITY.md +119 -0
  2. package/CHANGELOG.md +190 -9
  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 +331 -33
  15. package/ips/jurisdictions.js +65 -0
  16. package/ips/nationality.js +120 -0
  17. package/ips/pipeline.js +131 -17
  18. package/ips/presenter.js +1 -1
  19. package/ips/profile-editor.js +283 -4
  20. package/ips/readme.md +170 -20
  21. package/ips/render.js +769 -109
  22. package/ips/scripts.js +46 -0
  23. package/ips/store.js +123 -11
  24. package/ips/structure.js +247 -0
  25. package/ips/terminology.js +1 -1
  26. package/library/body-limit.js +22 -0
  27. package/library/cache-generation.js +15 -0
  28. package/library/cgroup-memory.js +133 -1
  29. package/library/cluster-counts.js +121 -0
  30. package/library/cluster-events.js +153 -0
  31. package/library/cluster-metrics.js +193 -0
  32. package/library/cluster-status.js +468 -0
  33. package/library/config-reader.js +96 -0
  34. package/library/daily-report.js +100 -2
  35. package/library/html-server.js +27 -1
  36. package/library/i18nsupport.js +23 -1
  37. package/library/keep-alive.js +18 -0
  38. package/library/languages.js +17 -3
  39. package/library/logger.js +31 -0
  40. package/library/memory-guard.js +428 -0
  41. package/library/native-memory.js +52 -0
  42. package/library/npm-audit.js +51 -25
  43. package/library/openapi-doc.js +7 -5
  44. package/library/package-manager.js +8 -0
  45. package/library/role.js +197 -0
  46. package/library/server-credentials.js +169 -0
  47. package/library/sqlite-utilities.js +59 -0
  48. package/library/ssrf-guard.js +100 -0
  49. package/library/static-response-stats.js +16 -0
  50. package/library/version-utilities.js +50 -0
  51. package/library/worker-count.js +192 -0
  52. package/nginx.md +30 -11
  53. package/npmprojector/indexer.js +18 -10
  54. package/package.json +11 -8
  55. package/packages/feed-check-page.js +117 -0
  56. package/packages/feed-check.js +279 -0
  57. package/packages/npm-check.js +57 -3
  58. package/packages/npm-signing.js +219 -0
  59. package/packages/openapi.yaml +207 -1
  60. package/packages/package-crawler.js +322 -41
  61. package/packages/packages-template.html +6 -8
  62. package/packages/packages.js +604 -57
  63. package/packages/readme.md +113 -1
  64. package/publisher/publisher-template.html +2 -5
  65. package/publisher/publisher.js +121 -110
  66. package/publisher/readme.md +75 -0
  67. package/registry/api.js +179 -1
  68. package/registry/crawler.js +86 -108
  69. package/registry/documentation/api.md +68 -2
  70. package/registry/documentation/configuration.md +34 -0
  71. package/registry/documentation/internals.md +4 -1
  72. package/registry/documentation/readme.md +3 -2
  73. package/registry/model.js +8 -0
  74. package/registry/openapi.yaml +84 -0
  75. package/registry/registry-template.html +5 -8
  76. package/registry/registry.js +58 -6
  77. package/root-bare-template.html +7 -4
  78. package/root-template.html +5 -8
  79. package/server.js +398 -198
  80. package/shl/shl.js +38 -37
  81. package/static/a11y/a11y-check.js +832 -0
  82. package/static/a11y/axe.min.js +12 -0
  83. package/static/assets/css/bootstrap-fhir.css +15 -15
  84. package/static/assets/images/flags/au.svg +18 -0
  85. package/static/assets/images/flags/unknown.svg +11 -0
  86. package/static/assets/images/flags/us.svg +63 -0
  87. package/static/assets/images/icon_audit.png +0 -0
  88. package/static/assets/images/icon_clinical.png +0 -0
  89. package/static/assets/images/icon_document.png +0 -0
  90. package/static/assets/images/icon_external.png +0 -0
  91. package/static/assets/images/icon_medication.png +0 -0
  92. package/static/assets/images/icon_organization.png +0 -0
  93. package/static/assets/images/icon_other.png +0 -0
  94. package/static/assets/images/icon_person.png +0 -0
  95. package/static/assets/images/icon_section.png +0 -0
  96. package/stats-db.js +45 -0
  97. package/stats.js +197 -10
  98. package/testing/testing-template.html +5 -8
  99. package/token/nginx_passport_setup.md +1 -1
  100. package/token/security_guide.md +1 -1
  101. package/token/token-template.html +2 -5
  102. package/token/token.js +40 -36
  103. package/translations/Messages.properties +19 -0
  104. package/tx/README.md +158 -4
  105. package/tx/closure/closure-store.js +126 -3
  106. package/tx/cm/cm-database.js +69 -57
  107. package/tx/cm/cm-package.js +50 -16
  108. package/tx/cs/cs-api.js +284 -19
  109. package/tx/cs/cs-api.md +41 -4
  110. package/tx/cs/cs-areacode.js +1 -1
  111. package/tx/cs/cs-country.js +17 -5
  112. package/tx/cs/cs-cpt.js +33 -16
  113. package/tx/cs/cs-cs.js +199 -24
  114. package/tx/cs/cs-currency.js +1 -1
  115. package/tx/cs/cs-icd11.js +21 -13
  116. package/tx/cs/cs-icf.js +22 -3
  117. package/tx/cs/cs-lang.js +2 -2
  118. package/tx/cs/cs-loinc.js +193 -67
  119. package/tx/cs/cs-mimetypes.js +12 -2
  120. package/tx/cs/cs-ndc.js +40 -25
  121. package/tx/cs/cs-omop.js +54 -29
  122. package/tx/cs/cs-rxnorm.js +300 -196
  123. package/tx/cs/cs-snomed.js +861 -95
  124. package/tx/cs/cs-ucum.js +14 -4
  125. package/tx/cs/cs-unii.js +18 -14
  126. package/tx/cs/cs-virtual.js +210 -0
  127. package/tx/ecosystem-resolver.js +413 -0
  128. package/tx/html/cluster-dash-metrics.liquid +26 -0
  129. package/tx/html/cluster-home-metrics.liquid +31 -0
  130. package/tx/html/cluster-metrics-script.liquid +251 -0
  131. package/tx/html/codesystem-operations.liquid +3 -3
  132. package/tx/html/ecl-panel.liquid +1 -1
  133. package/tx/html/external-metrics.liquid +17 -0
  134. package/tx/html/operations-form.liquid +17 -17
  135. package/tx/html/search-form.liquid +11 -11
  136. package/tx/html/status-line.liquid +58 -0
  137. package/tx/html/status-panel.liquid +140 -0
  138. package/tx/html/status-tasks.liquid +25 -0
  139. package/tx/html/tx-template.html +2 -5
  140. package/tx/importers/atc-to-fhir.js +17 -16
  141. package/tx/importers/export-icd11-codesystem.js +3 -2
  142. package/tx/importers/import-icd11.js +4 -3
  143. package/tx/importers/import-loinc.module.js +118 -56
  144. package/tx/importers/import-ndc.module.js +56 -44
  145. package/tx/importers/import-rxnorm.module.js +118 -79
  146. package/tx/importers/import-sct.module.js +125 -61
  147. package/tx/importers/import-unii.module.js +37 -36
  148. package/tx/importers/subset-loinc.module.js +49 -48
  149. package/tx/importers/subset-rxnorm.module.js +29 -28
  150. package/tx/importers/terminal.js +18 -0
  151. package/tx/importers/tx-import-base.js +13 -12
  152. package/tx/importers/tx-import-settings.js +5 -4
  153. package/tx/importers/tx-import.js +53 -52
  154. package/tx/library/canonical-resource.js +49 -0
  155. package/tx/library/codesystem.js +94 -5
  156. package/tx/library/designations.js +45 -2
  157. package/tx/library/extensions.js +13 -7
  158. package/tx/library/implicit-valuesets.js +66 -0
  159. package/tx/library/renderer.js +77 -21
  160. package/tx/library/sqlite-utilities.js +2 -0
  161. package/tx/library/tho-precedence.js +40 -0
  162. package/tx/library/ucum-parsers.js +11 -1
  163. package/tx/library/wait-for-cache.js +43 -0
  164. package/tx/library.js +321 -22
  165. package/tx/openapi-operations.js +6 -4
  166. package/tx/operation-context.js +460 -99
  167. package/tx/params.js +25 -0
  168. package/tx/problems.js +3 -5
  169. package/tx/provider.js +314 -67
  170. package/tx/resource-cache-sqlite.js +1050 -0
  171. package/tx/sct/documentation.md +45 -21
  172. package/tx/sct/ecl.js +81 -17
  173. package/tx/sct/editions.js +39 -0
  174. package/tx/sct/expressions.js +10 -1
  175. package/tx/sct/load-config.js +107 -0
  176. package/tx/sct/mmap.js +109 -0
  177. package/tx/sct/structures.js +175 -15
  178. package/tx/sct/text-index.js +5 -1
  179. package/tx/syndication/syndication-feed.js +2 -2
  180. package/tx/tx-html.js +44 -8
  181. package/tx/tx.fhir.org.yml +8 -8
  182. package/tx/tx.js +282 -80
  183. package/tx/usage-tracker.js +39 -11
  184. package/tx/vs/vs-api.js +19 -0
  185. package/tx/vs/vs-database.js +386 -62
  186. package/tx/vs/vs-package.js +44 -17
  187. package/tx/vs/vs-vsac.js +649 -101
  188. package/tx/workers/batch-validate.js +37 -10
  189. package/tx/workers/batch.js +6 -1
  190. package/tx/workers/cache-control.js +15 -4
  191. package/tx/workers/closure.js +156 -13
  192. package/tx/workers/compare.js +8 -5
  193. package/tx/workers/expand.js +1173 -289
  194. package/tx/workers/metadata.js +35 -0
  195. package/tx/workers/search.js +53 -1
  196. package/tx/workers/supplement-filters.js +145 -0
  197. package/tx/workers/translate.js +169 -4
  198. package/tx/workers/validate.js +223 -119
  199. package/tx/workers/worker.js +123 -31
  200. package/tx/xversion/xv-valueset.js +7 -4
  201. package/utilities/submit-test-report.js +3 -1
  202. package/utilities/tx-stress-test.js +482 -0
  203. package/vcl/readme.md +37 -0
  204. package/vcl/vcl-parser.js +15 -0
  205. package/xig/readme.md +52 -0
  206. package/xig/xig-template.html +5 -8
  207. package/xig/xig.js +238 -422
  208. package/changelog-template.md +0 -22
  209. package/registry/test-runner.js +0 -237
  210. package/release.sh +0 -80
  211. package/test-scripts/repro-re2-wasm-leak.js +0 -34
  212. package/tsconfig.eslint.json +0 -9
  213. package/tx/html/dash-metrics.liquid +0 -147
  214. package/tx/html/home-metrics.liquid +0 -225
@@ -3,9 +3,15 @@ const crypto = require('crypto');
3
3
  const sqlite3 = require('sqlite3').verbose();
4
4
  const { VersionUtilities } = require('../../library/version-utilities');
5
5
  const ValueSet = require("../library/valueset");
6
+ const { guarded } = require('../library/sqlite-utilities');
7
+ // the server log ({tx}), looked up when used (the database is opened by importers and tests too)
8
+ const txLog = () => require('../../library/logger').getInstance().child({ module: 'tx' });
6
9
 
7
10
  // Settings key written once a package cache has been fully populated (see isComplete)
8
11
  const CACHE_COMPLETE_KEY = 'cache-complete';
12
+ // A counter in vsac_settings that the VSAC sync bumps when it has written (cluster.md):
13
+ // a process that loaded the value set list at generation n is up to date while it says n
14
+ const GENERATION_KEY = 'generation';
9
15
 
10
16
  // Columns that can be returned directly without parsing JSON
11
17
  const INDEXED_COLUMNS = ['id', 'url', 'version', 'date', 'description', 'name', 'publisher', 'status', 'title'];
@@ -41,11 +47,22 @@ function isNewerVersion(candidate, current) {
41
47
  class ValueSetDatabase {
42
48
  vsCount;
43
49
 
50
+ // search() can build results with these elements from the table's columns
51
+ static INDEXED_COLUMNS = INDEXED_COLUMNS;
52
+
44
53
  /**
45
54
  * @param {string} dbPath - Path to the SQLite database file
55
+ * @param {Object} [options] - {shared: true} when several processes use the file (one
56
+ * writing, the others reading - cluster.md): WAL, so the readers don't block the writer,
57
+ * and a wait of up to 5 s for another process's lock instead of failing at once.
58
+ * {readOnly: true} for a cache another process builds (a tx-worker's package caches,
59
+ * cluster.md): opened read-only, never created or migrated - a file that still needs a
60
+ * migration fails to open (see _checkFinal), to be tried again once its builder is done
46
61
  */
47
- constructor(dbPath) {
62
+ constructor(dbPath, options = {}) {
48
63
  this.dbPath = dbPath;
64
+ this.shared = !!options.shared;
65
+ this.readOnly = !!options.readOnly;
49
66
  // Single read-write connection used for everything. Using a separate
50
67
  // OPEN_READONLY connection for reads can miss WAL-based schema changes
51
68
  // made through the write connection (because read-only opens can't fully
@@ -69,13 +86,17 @@ class ValueSetDatabase {
69
86
  // table_info` can race ahead of a `CREATE TABLE IF NOT EXISTS`, and
70
87
  // you get "no such table" errors on DDL that should have been fine.
71
88
  const run = (sql) => new Promise((res, rej) => {
72
- db.run(sql, [], (err) => err ? rej(err) : res());
89
+ db.run(sql, [], guarded(rej, (err) => err ? rej(err) : res()));
73
90
  });
74
91
  const all = (sql) => new Promise((res, rej) => {
75
- db.all(sql, [], (err, rows) => err ? rej(err) : res(rows));
92
+ db.all(sql, [], guarded(rej, (err, rows) => err ? rej(err) : res(rows)));
76
93
  });
77
94
 
78
95
  return (async () => {
96
+ if (this.shared) {
97
+ db.configure('busyTimeout', 5000);
98
+ await all('PRAGMA journal_mode = WAL');
99
+ }
79
100
  const cols = await all("PRAGMA table_info(valuesets)");
80
101
  const hasDateFirstSeen = cols.some(c => c.name === 'date_first_seen');
81
102
  const hasContentHash = cols.some(c => c.name === 'content_hash');
@@ -138,7 +159,7 @@ class ValueSetDatabase {
138
159
  );
139
160
  const missing = (needHash[0] && needHash[0].n) || 0;
140
161
  if (missing > 0) {
141
- console.log(`Backfilling content_hash for ${missing} existing value sets...`);
162
+ txLog().info(`Backfilling content_hash for ${missing} existing value sets...`);
142
163
  const rows = await all(
143
164
  "SELECT id, content FROM valuesets WHERE content_hash IS NULL"
144
165
  );
@@ -149,15 +170,15 @@ class ValueSetDatabase {
149
170
  db.run(
150
171
  'UPDATE valuesets SET content_hash = ? WHERE id = ?',
151
172
  [hash, row.id],
152
- (err) => err ? rej(err) : res()
173
+ guarded(rej, (err) => err ? rej(err) : res())
153
174
  );
154
175
  });
155
176
  done++;
156
177
  if (done % 1000 === 0) {
157
- console.log(` ...${done}/${missing}`);
178
+ txLog().info(` ...${done}/${missing}`);
158
179
  }
159
180
  }
160
- console.log(`Backfilled ${done} hashes.`);
181
+ txLog().info(`Backfilled ${done} hashes.`);
161
182
  }
162
183
  })();
163
184
  }
@@ -194,25 +215,92 @@ class ValueSetDatabase {
194
215
  if (this._migrationPromise) {
195
216
  return this._migrationPromise;
196
217
  }
218
+ if (this.readOnly) {
219
+ return this._openReadOnly();
220
+ }
197
221
  this._migrationPromise = new Promise((resolve, reject) => {
198
222
  if (this._writeDb) {
199
223
  this._migrateIfNeeded(this._writeDb).then(resolve).catch(reject);
200
224
  return;
201
225
  }
202
- this._writeDb = new sqlite3.Database(this.dbPath, (err) => {
226
+ this._writeDb = new sqlite3.Database(this.dbPath, guarded(reject, (err) => {
203
227
  if (err) {
204
228
  this._writeDb = null;
205
229
  reject(new Error(`Failed to open database for writing: ${err.message}`));
206
230
  return;
207
231
  }
208
232
  this._migrateIfNeeded(this._writeDb).then(resolve).catch(reject);
209
- });
233
+ }));
210
234
  });
211
235
  // If migration fails, clear the cached promise so a retry can attempt again
212
236
  this._migrationPromise.catch(() => { this._migrationPromise = null; });
213
237
  return this._migrationPromise;
214
238
  }
215
239
 
240
+ /**
241
+ * readOnly: open the file read-only, and check it needs no migration. On failure the
242
+ * connection is closed (the next use opens it again: the builder may have replaced it)
243
+ * @returns {Promise<void>}
244
+ * @private
245
+ */
246
+ _openReadOnly() {
247
+ this._migrationPromise = new Promise((resolve, reject) => {
248
+ const db = new sqlite3.Database(this.dbPath, sqlite3.OPEN_READONLY, (err) => {
249
+ if (err) {
250
+ reject(new Error(`Failed to open database ${this.dbPath} read-only: ${err.message}`));
251
+ return;
252
+ }
253
+ db.configure('busyTimeout', 5000);
254
+ this._checkFinal(db).then(() => {
255
+ this._writeDb = db;
256
+ resolve();
257
+ }, (error) => {
258
+ db.close(() => reject(error));
259
+ });
260
+ });
261
+ });
262
+ this._migrationPromise.catch(() => { this._migrationPromise = null; });
263
+ return this._migrationPromise;
264
+ }
265
+
266
+ /**
267
+ * Reject unless the file is as _migrateIfNeeded leaves it: every column and table there,
268
+ * and no row without its content_hash
269
+ * @param {sqlite3.Database} db
270
+ * @returns {Promise<void>}
271
+ * @private
272
+ */
273
+ async _checkFinal(db) {
274
+ const all = (sql) => new Promise((res, rej) => {
275
+ db.all(sql, [], (err, rows) => err ? rej(err) : res(rows));
276
+ });
277
+ const columns = async (table) => (await all(`PRAGMA table_info(${table})`)).map(c => c.name);
278
+ const vsCols = await columns('valuesets');
279
+ const pending = [];
280
+ for (const col of ['date_first_seen', 'content_hash']) {
281
+ if (!vsCols.includes(col)) {
282
+ pending.push(`valuesets.${col}`);
283
+ }
284
+ }
285
+ if (!(await columns('vsac_runs')).includes('total_updated')) {
286
+ pending.push('vsac_runs.total_updated');
287
+ }
288
+ for (const table of ['vsac_settings', 'vsac_events']) {
289
+ if ((await columns(table)).length === 0) {
290
+ pending.push(table);
291
+ }
292
+ }
293
+ if (pending.length === 0) {
294
+ const rows = await all('SELECT COUNT(*) AS n FROM valuesets WHERE content_hash IS NULL');
295
+ if (rows[0] && rows[0].n > 0) {
296
+ pending.push(`content_hash of ${rows[0].n} value sets`);
297
+ }
298
+ }
299
+ if (pending.length > 0) {
300
+ throw new Error(`Database ${this.dbPath} is not migrated yet (${pending.join(', ')}), and is opened read-only`);
301
+ }
302
+ }
303
+
216
304
  /**
217
305
  * Close all database connections
218
306
  * @returns {Promise<void>}
@@ -227,7 +315,7 @@ class ValueSetDatabase {
227
315
 
228
316
  await new Promise((resolve) => {
229
317
  this._writeDb.close((err) => {
230
- if (err) console.warn(`Warning closing database connection: ${err.message}`);
318
+ if (err) txLog().warn(`Warning closing database connection: ${err.message}`);
231
319
  this._writeDb = null;
232
320
  resolve();
233
321
  });
@@ -256,7 +344,7 @@ class ValueSetDatabase {
256
344
  await this.close();
257
345
 
258
346
  return new Promise((resolve, reject) => {
259
- const db = new sqlite3.Database(this.dbPath, (err) => {
347
+ const db = new sqlite3.Database(this.dbPath, guarded(reject, (err) => {
260
348
  if (err) {
261
349
  reject(new Error(`Failed to create database ${this.dbPath}: ${err.message}`));
262
350
  return;
@@ -378,7 +466,7 @@ class ValueSetDatabase {
378
466
  }
379
467
  });
380
468
  });
381
- });
469
+ }));
382
470
  });
383
471
  }
384
472
 
@@ -455,6 +543,36 @@ class ValueSetDatabase {
455
543
  });
456
544
  }
457
545
 
546
+ /**
547
+ * The most recent VSAC sync run, or null if there has never been one
548
+ * @returns {Promise<{id: number, status: string, started_at: number, finished_at: number|null}|null>}
549
+ */
550
+ async getLastRun() {
551
+ const db = await this._getReadConnection();
552
+ return new Promise((resolve, reject) => {
553
+ db.get('SELECT id, status, started_at, finished_at FROM vsac_runs ORDER BY id DESC LIMIT 1', [],
554
+ (err, row) => err ? reject(err) : resolve(row || null));
555
+ });
556
+ }
557
+
558
+ /**
559
+ * Close off runs still marked 'running'. Only one process syncs a cache, so at
560
+ * startup any such run was cut short when the server stopped.
561
+ * @returns {Promise<number>} the number of runs marked as interrupted
562
+ */
563
+ async markInterruptedRuns() {
564
+ const db = await this._getWriteConnection();
565
+ return new Promise((resolve, reject) => {
566
+ db.run(
567
+ `UPDATE vsac_runs SET finished_at = strftime('%s','now'), status = 'error',
568
+ error_message = 'interrupted (server stopped during the sync)'
569
+ WHERE status = 'running'`,
570
+ [],
571
+ function(err) { err ? reject(err) : resolve(this.changes); }
572
+ );
573
+ });
574
+ }
575
+
458
576
  /**
459
577
  * Get a setting value from the vsac_settings table
460
578
  * @param {string} key - The setting key
@@ -463,10 +581,32 @@ class ValueSetDatabase {
463
581
  async getSetting(key) {
464
582
  const db = await this._getReadConnection();
465
583
  return new Promise((resolve, reject) => {
466
- db.get('SELECT value FROM vsac_settings WHERE key = ?', [key], (err, row) => {
584
+ db.get('SELECT value FROM vsac_settings WHERE key = ?', [key], guarded(reject, (err, row) => {
467
585
  if (err) reject(err);
468
586
  else resolve(row ? row.value : null);
469
- });
587
+ }));
588
+ });
589
+ }
590
+
591
+ /**
592
+ * The generation (see GENERATION_KEY): 0 until a sync has bumped it. One read by key.
593
+ * @returns {Promise<number>}
594
+ */
595
+ async getGeneration() {
596
+ const value = await this.getSetting(GENERATION_KEY);
597
+ return value === null ? 0 : Number(value);
598
+ }
599
+
600
+ /**
601
+ * Add one to the generation, in one statement.
602
+ * @returns {Promise<void>}
603
+ */
604
+ async bumpGeneration() {
605
+ const db = await this._getWriteConnection();
606
+ return new Promise((resolve, reject) => {
607
+ db.run(`INSERT INTO vsac_settings (key, value) VALUES (?, '1')
608
+ ON CONFLICT(key) DO UPDATE SET value = CAST(value AS INTEGER) + 1`,
609
+ [GENERATION_KEY], err => err ? reject(err) : resolve());
470
610
  });
471
611
  }
472
612
 
@@ -488,6 +628,18 @@ class ValueSetDatabase {
488
628
  });
489
629
  }
490
630
 
631
+ /**
632
+ * Remove a setting from the vsac_settings table
633
+ * @param {string} key - The setting key
634
+ * @returns {Promise<void>}
635
+ */
636
+ async deleteSetting(key) {
637
+ const db = await this._getWriteConnection();
638
+ return new Promise((resolve, reject) => {
639
+ db.run('DELETE FROM vsac_settings WHERE key = ?', [key], err => err ? reject(err) : resolve());
640
+ });
641
+ }
642
+
491
643
  /**
492
644
  * True if this cache was fully populated: markComplete() was reached. A cache
493
645
  * whose population was cut short (the process killed part way through) has no
@@ -514,31 +666,35 @@ class ValueSetDatabase {
514
666
  /**
515
667
  * Insert or update a single ValueSet in the database
516
668
  * @param {Object} valueSet - The ValueSet resource
517
- * @param {string} [contentHash] - Optional pre-computed content hash to store
669
+ * @param {string} [contentHash] - Optional pre-computed content hash to store; by default the
670
+ * hash of the content stored, as the backfill in _migrateIfNeeded computes it, so no row is
671
+ * written without one (a row without one is backfilled by the next process to open the file)
518
672
  * @returns {Promise<void>}
519
673
  */
520
674
  async upsertValueSet(valueSet, contentHash = null) {
521
675
  if (!valueSet.url) {
522
676
  throw new Error('ValueSet must have a url property');
523
677
  }
678
+ const content = JSON.stringify(valueSet);
679
+ contentHash = contentHash || crypto.createHash('sha256').update(content).digest('hex');
524
680
 
525
681
  const db = await this._getWriteConnection();
526
682
 
527
683
  return new Promise((resolve, reject) => {
528
684
  // Step 1: Delete existing related records
529
- db.run('DELETE FROM valueset_identifiers WHERE valueset_id = ?', [valueSet.id], (err) => {
685
+ db.run('DELETE FROM valueset_identifiers WHERE valueset_id = ?', [valueSet.id], guarded(reject, (err) => {
530
686
  if (err) {
531
687
  reject(new Error(`Failed to delete identifiers: ${err.message}`));
532
688
  return;
533
689
  }
534
690
 
535
- db.run('DELETE FROM valueset_jurisdictions WHERE valueset_id = ?', [valueSet.id], (err) => {
691
+ db.run('DELETE FROM valueset_jurisdictions WHERE valueset_id = ?', [valueSet.id], guarded(reject, (err) => {
536
692
  if (err) {
537
693
  reject(new Error(`Failed to delete jurisdictions: ${err.message}`));
538
694
  return;
539
695
  }
540
696
 
541
- db.run('DELETE FROM valueset_systems WHERE valueset_id = ?', [valueSet.id], (err) => {
697
+ db.run('DELETE FROM valueset_systems WHERE valueset_id = ?', [valueSet.id], guarded(reject, (err) => {
542
698
  if (err) {
543
699
  reject(new Error(`Failed to delete systems: ${err.message}`));
544
700
  return;
@@ -583,9 +739,9 @@ class ValueSetDatabase {
583
739
  valueSet.publisher || null,
584
740
  valueSet.status || null,
585
741
  valueSet.title || null,
586
- JSON.stringify(valueSet),
742
+ content,
587
743
  contentHash
588
- ], (err) => {
744
+ ], guarded(reject, (err) => {
589
745
  if (err) {
590
746
  reject(new Error(`Failed to insert main record: ${err.message}`));
591
747
  return;
@@ -593,10 +749,10 @@ class ValueSetDatabase {
593
749
 
594
750
  // Step 3: Insert related records
595
751
  this._insertRelatedRecords(db, valueSet, resolve, reject);
596
- });
597
- });
598
- });
599
- });
752
+ }));
753
+ }));
754
+ }));
755
+ }));
600
756
  });
601
757
  }
602
758
 
@@ -758,7 +914,7 @@ class ValueSetDatabase {
758
914
  const db = await this._getReadConnection();
759
915
 
760
916
  return new Promise((resolve, reject) => {
761
- db.all('SELECT id, url, version, content, content_hash FROM valuesets', [], (err, rows) => {
917
+ db.all('SELECT id, url, version, content, content_hash FROM valuesets', [], guarded(reject, (err, rows) => {
762
918
  if (err) {
763
919
  reject(new Error(`Failed to load value sets: ${err.message}`));
764
920
  return;
@@ -769,21 +925,11 @@ class ValueSetDatabase {
769
925
  const valueSetMap = new Map();
770
926
 
771
927
  for (const row of rows) {
772
- let valueSet;
773
- try {
774
- valueSet = new ValueSet(JSON.parse(row.content));
775
- } catch (e) {
776
- // one unacceptable value set (e.g. one containing a CodeSystem, which the
777
- // server doesn't support) must not take the whole package's value sets with it
778
- require('../../library/logger').getInstance().child({ module: 'tx' })
779
- .warn(`${source || 'ValueSet database'}: skipping ValueSet ${row.url || row.id}${row.version ? '|' + row.version : ''}: ${e.message}`);
928
+ const valueSet = this._rowToValueSet(row, source);
929
+ if (!valueSet) {
780
930
  this.vsCount--;
781
931
  continue;
782
932
  }
783
- valueSet.sourcePackage = source;
784
- // Attach the stored content hash so callers can detect changes
785
- // without recomputing over the full JSON.
786
- valueSet.contentHash = row.content_hash || null;
787
933
  // Store by URL and id alone
788
934
  this.addToMap(valueSetMap, row.id, row.url, row.version, valueSet);
789
935
  }
@@ -792,10 +938,135 @@ class ValueSetDatabase {
792
938
  } catch (error) {
793
939
  reject(new Error(`Failed to parse value set content: ${error.message}`));
794
940
  }
795
- });
941
+ }));
942
+ });
943
+ }
944
+
945
+ /**
946
+ * Build the ValueSet for a row (id, url, version, content, content_hash) - or null if the
947
+ * content isn't an acceptable value set, which is logged and then treated as absent
948
+ * @param {Object} row
949
+ * @param {string} source
950
+ * @returns {ValueSet|null}
951
+ * @private
952
+ */
953
+ _rowToValueSet(row, source) {
954
+ let valueSet;
955
+ try {
956
+ valueSet = new ValueSet(JSON.parse(row.content));
957
+ } catch (e) {
958
+ // one unacceptable value set (e.g. one containing a CodeSystem, which the
959
+ // server doesn't support) must not take the whole package's value sets with it
960
+ require('../../library/logger').getInstance().child({ module: 'tx' })
961
+ .warn(`${source || 'ValueSet database'}: skipping ValueSet ${row.url || row.id}${row.version ? '|' + row.version : ''}: ${e.message}`);
962
+ return null;
963
+ }
964
+ valueSet.sourcePackage = source;
965
+ // Attach the stored content hash so callers can detect changes
966
+ // without recomputing over the full JSON.
967
+ valueSet.contentHash = row.content_hash || null;
968
+ return valueSet;
969
+ }
970
+
971
+ /**
972
+ * Read one ValueSet by id, for providers that read value sets on demand instead of
973
+ * holding them all in memory (see loadAllValueSets)
974
+ * @param {string} id
975
+ * @param {string} source
976
+ * @returns {Promise<ValueSet|null>} null if there's no such row, or it was rejected
977
+ */
978
+ async loadValueSet(id, source) {
979
+ const db = await this._getReadConnection();
980
+ const row = await new Promise((resolve, reject) => {
981
+ db.get('SELECT id, url, version, content, content_hash FROM valuesets WHERE id = ?', [id],
982
+ (err, row) => err ? reject(new Error(`Failed to load value set ${id}: ${err.message}`)) : resolve(row));
983
+ });
984
+ const vs = row ? this._rowToValueSet(row, source) : null;
985
+ if (vs) {
986
+ // the size of what it was read from, for a provider that keeps some (see VSAC RecentValueSets)
987
+ vs.storedLength = row.content.length;
988
+ }
989
+ return vs;
990
+ }
991
+
992
+ /**
993
+ * The id and version of every row for a url (or every row, for null), in the order loadAllValueSets sees them -
994
+ * enough to work out which one a url (and version) refers to (see addToMap) without
995
+ * reading any content
996
+ * @param {string} url
997
+ * @returns {Promise<Array<{id: string, url: string, version: string}>>}
998
+ */
999
+ async listVersions(url) {
1000
+ const db = await this._getReadConnection();
1001
+ return new Promise((resolve, reject) => {
1002
+ db.all(url == null ? 'SELECT id, url, version FROM valuesets ORDER BY rowid' : 'SELECT id, url, version FROM valuesets WHERE url = ? ORDER BY rowid', url == null ? [] : [url],
1003
+ (err, rows) => err ? reject(new Error(`Failed to list versions of ${url}: ${err.message}`)) : resolve(rows));
796
1004
  });
797
1005
  }
798
1006
 
1007
+ /**
1008
+ * Read several ValueSets by id
1009
+ * @param {string[]} ids
1010
+ * @param {string} source
1011
+ * @returns {Promise<Map<string, ValueSet>>} id -> ValueSet, for the ids there are acceptable rows for
1012
+ */
1013
+ async loadValueSets(ids, source) {
1014
+ const db = await this._getReadConnection();
1015
+ const result = new Map();
1016
+ for (const row of (await this._readRows(db, ids)).values()) {
1017
+ const vs = this._rowToValueSet(row, source);
1018
+ if (vs) {
1019
+ result.set(row.id, vs);
1020
+ }
1021
+ }
1022
+ return result;
1023
+ }
1024
+
1025
+ /**
1026
+ * The stored content hash for url|version, or undefined if there's no such row
1027
+ * @param {string} url
1028
+ * @param {string} version
1029
+ * @returns {Promise<{id: string, content_hash: string}|undefined>}
1030
+ */
1031
+ async getVersion(url, version) {
1032
+ const db = await this._getReadConnection();
1033
+ return new Promise((resolve, reject) => {
1034
+ db.get('SELECT id, content_hash FROM valuesets WHERE url = ? AND version = ?', [url, version],
1035
+ (err, row) => err ? reject(new Error(`Failed to read ${url}|${version}: ${err.message}`)) : resolve(row));
1036
+ });
1037
+ }
1038
+
1039
+ /**
1040
+ * Every url|version in the database (as a Set of 'url|version' strings)
1041
+ * @returns {Promise<Set<string>>}
1042
+ */
1043
+ async listVersionKeys() {
1044
+ const db = await this._getReadConnection();
1045
+ return new Promise((resolve, reject) => {
1046
+ db.all('SELECT url, version FROM valuesets WHERE version IS NOT NULL', [], guarded(reject, (err, rows) => {
1047
+ if (err) {
1048
+ reject(new Error(`Failed to list value set versions: ${err.message}`));
1049
+ } else {
1050
+ resolve(new Set(rows.map(row => `${row.url}|${row.version}`)));
1051
+ }
1052
+ }));
1053
+ });
1054
+ }
1055
+
1056
+ /**
1057
+ * Count the value sets (sets vsCount), and the distinct urls among them
1058
+ * @returns {Promise<{total: number, urls: number}>}
1059
+ */
1060
+ async countValueSets() {
1061
+ const db = await this._getReadConnection();
1062
+ const row = await new Promise((resolve, reject) => {
1063
+ db.get('SELECT COUNT(*) AS total, COUNT(DISTINCT url) AS urls FROM valuesets', [],
1064
+ (err, row) => err ? reject(new Error(`Failed to count value sets: ${err.message}`)) : resolve(row));
1065
+ });
1066
+ this.vsCount = row.total;
1067
+ return row;
1068
+ }
1069
+
799
1070
  addToMap(valueSetMap, id, url, version, valueSet) {
800
1071
  // The bare-url key must resolve to the *latest* version, but rows arrive in no
801
1072
  // particular order (and several versions of the same url coexist - e.g. VSAC, where
@@ -840,17 +1111,22 @@ class ValueSetDatabase {
840
1111
  const canOptimize = elements && elements.length > 0 &&
841
1112
  elements.every(e => INDEXED_COLUMNS.includes(e));
842
1113
 
843
- // Always include 'id' in the columns to select when optimizing
844
- const columnsToSelect = canOptimize
1114
+ // Always include 'id' in the columns to select when optimizing (and the version, when the
1115
+ // results are matched on it - see versionSearchMatches)
1116
+ const versionAsked = searchParams.filter(p => p.name.toLowerCase() === 'version').map(p => p.value);
1117
+ let columnsToSelect = canOptimize
845
1118
  ? (elements.includes('id') ? elements : ['id', ...elements])
846
1119
  : null;
1120
+ if (columnsToSelect && versionAsked.length > 0 && !columnsToSelect.includes('version')) {
1121
+ columnsToSelect = [...columnsToSelect, 'version'];
1122
+ }
847
1123
 
848
1124
  const db = await this._getReadConnection();
849
1125
 
850
1126
  return new Promise((resolve, reject) => {
851
1127
  const { query, params } = this._buildSearchQuery(searchParams, columnsToSelect);
852
1128
 
853
- db.all(query, params, (err, rows) => {
1129
+ db.all(query, params, guarded(reject, (err, rows) => {
854
1130
  if (err) {
855
1131
  reject(new Error(`Search query failed: ${err.message}`));
856
1132
  return;
@@ -879,12 +1155,57 @@ class ValueSetDatabase {
879
1155
  // (see loadAllValueSets), so it isn't a result
880
1156
  results = rows.map(row => map.get(row.id)).filter(vs => vs);
881
1157
  }
1158
+ for (const criteria of versionAsked) {
1159
+ results = results.filter(r => {
1160
+ const json = r.jsonObj || r;
1161
+ return VersionUtilities.versionSearchMatches(criteria, json.version, VersionUtilities.versionAlgorithmOf(json));
1162
+ });
1163
+ }
882
1164
 
883
1165
  resolve(results);
884
1166
  } catch (error) {
885
1167
  reject(new Error(`Failed to parse search results: ${error.message}`));
886
1168
  }
887
- });
1169
+ }));
1170
+ });
1171
+ }
1172
+
1173
+ /**
1174
+ * The rows that match a search, in the order search() finds them, with only the indexed
1175
+ * columns a search is sorted by - for a provider that reads the matching value sets on demand,
1176
+ * and only reads the ones it has to return
1177
+ * @param {Array<{name: string, value: string}>} searchParams
1178
+ * @returns {Promise<Array<{id: string, url: string, version: string, date: string, name: string}>>}
1179
+ */
1180
+ async searchRows(searchParams) {
1181
+ const db = await this._getReadConnection();
1182
+ const { query, params } = this._buildSearchQuery(searchParams, ['id', 'url', 'version', 'date', 'name']);
1183
+ const rows = await new Promise((resolve, reject) => {
1184
+ db.all(query, params, (err, rows) => err ? reject(new Error(`Search query failed: ${err.message}`)) : resolve(rows));
1185
+ });
1186
+ // the query only narrows a version search to the versions that could match: they are matched
1187
+ // here, as search() matches them (on the version alone: the rows have no versionAlgorithm)
1188
+ const versionAsked = searchParams.filter(p => p.name.toLowerCase() === 'version').map(p => p.value);
1189
+ return rows.filter(row => versionAsked.every(criteria => VersionUtilities.versionSearchMatches(criteria, row.version)));
1190
+ }
1191
+
1192
+ /**
1193
+ * Read the rows for a list of ids
1194
+ * @param {sqlite3.Database} db
1195
+ * @param {string[]} ids
1196
+ * @returns {Promise<Map<string, Object>>} id -> row (id, url, version, content, content_hash)
1197
+ * @private
1198
+ */
1199
+ _readRows(db, ids) {
1200
+ return new Promise((resolve, reject) => {
1201
+ db.all('SELECT id, url, version, content, content_hash FROM valuesets WHERE id IN (SELECT value FROM json_each(?))',
1202
+ [JSON.stringify(ids)], guarded(reject, (err, rows) => {
1203
+ if (err) {
1204
+ reject(new Error(`Failed to read value sets: ${err.message}`));
1205
+ } else {
1206
+ resolve(new Map(rows.map(row => [row.id, row])));
1207
+ }
1208
+ }));
888
1209
  });
889
1210
  }
890
1211
 
@@ -898,7 +1219,7 @@ class ValueSetDatabase {
898
1219
 
899
1220
  return new Promise((resolve, reject) => {
900
1221
  // Get URLs to delete first
901
- db.all('SELECT url FROM valuesets WHERE last_seen < ?', [cutoffTimestamp], (err, rows) => {
1222
+ db.all('SELECT url FROM valuesets WHERE last_seen < ?', [cutoffTimestamp], guarded(reject, (err, rows) => {
902
1223
  if (err) {
903
1224
  reject(new Error(`Failed to find old records: ${err.message}`));
904
1225
  return;
@@ -941,21 +1262,21 @@ class ValueSetDatabase {
941
1262
 
942
1263
  pendingDeletes = 3; // identifiers, jurisdictions, systems
943
1264
 
944
- db.run(`DELETE FROM valueset_identifiers WHERE valueset_id IN (${placeholders})`, idsToDelete, (err) => {
1265
+ db.run(`DELETE FROM valueset_identifiers WHERE valueset_id IN (${placeholders})`, idsToDelete, guarded(reject, (err) => {
945
1266
  if (err) deleteError(new Error(`Failed to delete identifier records: ${err.message}`));
946
1267
  else deleteComplete();
947
- });
1268
+ }));
948
1269
 
949
- db.run(`DELETE FROM valueset_jurisdictions WHERE valueset_id IN (${placeholders})`, idsToDelete, (err) => {
1270
+ db.run(`DELETE FROM valueset_jurisdictions WHERE valueset_id IN (${placeholders})`, idsToDelete, guarded(reject, (err) => {
950
1271
  if (err) deleteError(new Error(`Failed to delete jurisdiction records: ${err.message}`));
951
1272
  else deleteComplete();
952
- });
1273
+ }));
953
1274
 
954
- db.run(`DELETE FROM valueset_systems WHERE valueset_id IN (${placeholders})`, idsToDelete, (err) => {
1275
+ db.run(`DELETE FROM valueset_systems WHERE valueset_id IN (${placeholders})`, idsToDelete, guarded(reject, (err) => {
955
1276
  if (err) deleteError(new Error(`Failed to delete system records: ${err.message}`));
956
1277
  else deleteComplete();
957
- });
958
- });
1278
+ }));
1279
+ }));
959
1280
  });
960
1281
  }
961
1282
 
@@ -992,17 +1313,17 @@ class ValueSetDatabase {
992
1313
  };
993
1314
 
994
1315
  // Total count
995
- db.get(queries[0], [], (err, row) => {
1316
+ db.get(queries[0], [], guarded(reject, (err, row) => {
996
1317
  if (err) {
997
1318
  handleError(err);
998
1319
  return;
999
1320
  }
1000
1321
  results.totalValueSets = row.total;
1001
1322
  checkComplete();
1002
- });
1323
+ }));
1003
1324
 
1004
1325
  // Status breakdown
1005
- db.all(queries[1], [], (err, rows) => {
1326
+ db.all(queries[1], [], guarded(reject, (err, rows) => {
1006
1327
  if (err) {
1007
1328
  handleError(err);
1008
1329
  return;
@@ -1012,17 +1333,17 @@ class ValueSetDatabase {
1012
1333
  results.byStatus[row.status || 'null'] = row.count;
1013
1334
  }
1014
1335
  checkComplete();
1015
- });
1336
+ }));
1016
1337
 
1017
1338
  // System count
1018
- db.get(queries[2], [], (err, row) => {
1339
+ db.get(queries[2], [], guarded(reject, (err, row) => {
1019
1340
  if (err) {
1020
1341
  handleError(err);
1021
1342
  return;
1022
1343
  }
1023
1344
  results.totalSystems = row.systems;
1024
1345
  checkComplete();
1025
- });
1346
+ }));
1026
1347
  });
1027
1348
  }
1028
1349
 
@@ -1043,13 +1364,16 @@ class ValueSetDatabase {
1043
1364
 
1044
1365
  switch (name.toLowerCase()) {
1045
1366
  case 'url':
1046
- conditions.push('v.url = ?');
1047
- params.push(value);
1367
+ // a left match (case-sensitive): the url starts with the value
1368
+ conditions.push('substr(v.url, 1, length(?)) = ?');
1369
+ params.push(value, value);
1048
1370
  break;
1049
1371
 
1050
1372
  case 'version':
1051
- conditions.push('v.version LIKE ?');
1052
- params.push(`%${value}%`);
1373
+ // version-aware matching (see VersionUtilities.versionSearchMatches): narrowed here to
1374
+ // the versions that could match, and matched on the results (see search)
1375
+ conditions.push("v.version LIKE ? ESCAPE '\\'");
1376
+ params.push(VersionUtilities.versionSearchPrefix(value).replace(/[\\%_]/g, c => '\\' + c) + '%');
1053
1377
  break;
1054
1378
 
1055
1379
  case 'name':
@@ -1149,7 +1473,7 @@ class ValueSetDatabase {
1149
1473
  const db = await this._getReadConnection();
1150
1474
 
1151
1475
  return new Promise((resolve, reject) => {
1152
- db.all('SELECT url FROM valuesets ORDER BY url', [], (err, rows) => {
1476
+ db.all('SELECT url FROM valuesets ORDER BY url', [], guarded(reject, (err, rows) => {
1153
1477
  if (err) {
1154
1478
  reject(new Error(`Failed to list value sets: ${err.message}`));
1155
1479
  return;
@@ -1157,7 +1481,7 @@ class ValueSetDatabase {
1157
1481
 
1158
1482
  const urls = rows.map(row => row.url);
1159
1483
  resolve(urls);
1160
- });
1484
+ }));
1161
1485
  });
1162
1486
  }
1163
1487
  }