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
@@ -11,6 +11,11 @@
11
11
  // system/codeSystem entry validates against a code system), so both batch routes
12
12
  // share one implementation and even a mixed batch is handled correctly.
13
13
  //
14
+ // The exception is `url`, which names a value set to ValueSet/$validate-code but a
15
+ // code system to CodeSystem/$validate-code. So on the CodeSystem route `url` is a
16
+ // shared global (the code system), and every entry is a CodeSystem validation;
17
+ // only the ValueSet route dispatches by shape.
18
+ //
14
19
 
15
20
  const { TerminologyWorker } = require('./worker');
16
21
  const { OperationOutcome, Issue, buildOperationOutcome, outcomeFromError } = require('../library/operation-outcome');
@@ -33,14 +38,15 @@ class BatchValidateWorker extends TerminologyWorker {
33
38
  super(opContext, log, provider, languages, i18n);
34
39
  // "Global" parameters may be supplied once at the top level and are applied to
35
40
  // every entry that doesn't override them. tx-resource is always shared. The
36
- // primary differs by operation: a ValueSet batch shares url/valueSet, a
37
- // CodeSystem batch shares system/codeSystem.
41
+ // primary differs by operation: a ValueSet batch shares url/valueSet (the value
42
+ // set), a CodeSystem batch shares url/version/codeSystem (the code system), as
43
+ // CodeSystem/$validate-code names them - and system, which it also accepts.
38
44
  this.valueSetGlobalNames = new Set([
39
45
  "tx-resource", "url", "valueSet", "lenient-display-validation",
40
46
  "__Accept-Language", "__Content-Language"
41
47
  ]);
42
48
  this.codeSystemGlobalNames = new Set([
43
- "tx-resource", "system", "codeSystem", "lenient-display-validation",
49
+ "tx-resource", "url", "version", "system", "codeSystem", "lenient-display-validation",
44
50
  "__Accept-Language", "__Content-Language"
45
51
  ]);
46
52
  // Back-compat: `globalNames` was the (ValueSet) set before CodeSystem batches.
@@ -57,12 +63,12 @@ class BatchValidateWorker extends TerminologyWorker {
57
63
 
58
64
  /** ValueSet/$batch-validate-code: shared globals are url/valueSet. */
59
65
  async handleValueSet(req, res) {
60
- return this.processBatch(req, res, this.valueSetGlobalNames);
66
+ return this.processBatch(req, res, this.valueSetGlobalNames, false);
61
67
  }
62
68
 
63
- /** CodeSystem/$batch-validate-code: shared globals are system/codeSystem. */
69
+ /** CodeSystem/$batch-validate-code: shared globals are url/version/system/codeSystem. */
64
70
  async handleCodeSystem(req, res) {
65
- return this.processBatch(req, res, this.codeSystemGlobalNames);
71
+ return this.processBatch(req, res, this.codeSystemGlobalNames, true);
66
72
  }
67
73
 
68
74
  /**
@@ -74,8 +80,11 @@ class BatchValidateWorker extends TerminologyWorker {
74
80
  * @param {express.Request} req
75
81
  * @param {express.Response} res
76
82
  * @param {Set<string>} globalNames - which top-level params are shared globals
83
+ * @param {boolean} codeSystemRoute - true for CodeSystem/$batch-validate-code: every
84
+ * entry is a CodeSystem validation (`url` there is the code system). On the
85
+ * ValueSet route, an entry with no url/valueSet is a CodeSystem validation
77
86
  */
78
- async processBatch(req, res, globalNames) {
87
+ async processBatch(req, res, globalNames, codeSystemRoute = false) {
79
88
  try {
80
89
  let params = req.body;
81
90
  this.addHttpParams(req, params);
@@ -120,16 +129,32 @@ class BatchValidateWorker extends TerminologyWorker {
120
129
  : p.resource.parameter;
121
130
  op.jsonObj.parameter.push(...entryParams);
122
131
 
123
- let worker = new ValidateWorker(this.opContext.copy(), this.log, this.provider, this.languages, this.i18n);
132
+ // Each entry gets its own provider list, closed as soon as the entry is
133
+ // answered. Sharing the batch's list would keep every entry's providers
134
+ // (each with an open sqlite connection, and a reference to the entry's
135
+ // context and its log) alive until the whole batch ends.
136
+ const entryContext = this.opContext.copy({ ownProviders: true });
137
+ let worker = new ValidateWorker(entryContext, this.log, this.provider, this.languages, this.i18n);
124
138
  try {
139
+ // Validating an entry never yields the event loop by itself, and IG builds send batches
140
+ // of up to 1,000 entries (~1 s of compute): give other requests a turn between entries
141
+ // (checkAndYield yields only after YIELD_INTERVAL ms of compute, so small batches don't),
142
+ // and see a disconnect. Inside the try: the time limit tripping here is this entry's
143
+ // too-costly, and the entries already answered keep their answers; a memory abort or
144
+ // a disconnect is rethrown below and stops the whole batch.
145
+ await this.opContext.checkAndYield('batch-validate');
125
146
  let p;
126
- if (this.hasValueSet(op.jsonObj.parameter)) {
147
+ if (!codeSystemRoute && this.hasValueSet(op.jsonObj.parameter)) {
127
148
  p = await worker.handleValueSetInner(op.jsonObj);
128
149
  } else {
129
150
  p = await worker.handleCodeSystemInner(op.jsonObj);
130
151
  }
131
152
  output.push({name: "validation", resource : p});
132
153
  } catch (error) {
154
+ // the client has gone, or memory is short: the whole batch stops, not this entry
155
+ if (error.abandoned || error.memoryAbort) {
156
+ throw error;
157
+ }
133
158
  this.log.error(error);
134
159
  debugLog(error);
135
160
  if (error instanceof Issue) {
@@ -139,6 +164,8 @@ class BatchValidateWorker extends TerminologyWorker {
139
164
  } else {
140
165
  output.push({name: "validation", resource : outcomeFromError(error) } );
141
166
  }
167
+ } finally {
168
+ await entryContext.closeProviders();
142
169
  }
143
170
  }
144
171
  }
@@ -209,7 +236,7 @@ class BatchValidateWorker extends TerminologyWorker {
209
236
  const { txResources, primaryResources } = this.collectSuppliedResources({ parameter: allParams });
210
237
  const pool = txResources.concat(primaryResources);
211
238
  if (pool.length > 0) {
212
- cache.add(cacheId, pool);
239
+ this.addToClientCache(cacheId, pool);
213
240
  }
214
241
  return true;
215
242
  }
@@ -79,7 +79,8 @@ class BatchWorker extends TerminologyWorker {
79
79
  const responseEntries = [];
80
80
 
81
81
  for (let i = 0; i < entries.length; i++) {
82
- this.deadCheck(`batch-entry-${i}`);
82
+ // a turn for the event loop between entries, so a disconnect is seen
83
+ await this.checkAndYield(`batch-entry-${i}`);
83
84
  const entry = entries[i];
84
85
  const responseEntry = await this.processEntry(entry, i);
85
86
  responseEntries.push(responseEntry);
@@ -159,6 +160,10 @@ class BatchWorker extends TerminologyWorker {
159
160
  };
160
161
 
161
162
  } catch (error) {
163
+ // the client has gone, or memory is short: the whole batch stops, not this entry
164
+ if (error.abandoned || error.memoryAbort) {
165
+ throw error;
166
+ }
162
167
  this.log.error(error);
163
168
  debugLog(error);
164
169
  const statusCode = error.statusCode || 500;
@@ -97,7 +97,8 @@ class CacheControlWorker extends TerminologyWorker {
97
97
 
98
98
  /**
99
99
  * mode=start: mint a server-issued cache-id, create the (per-endpoint) cache,
100
- * front-load any supplied resources into it, and return the id in the body.
100
+ * front-load any supplied resources into it, and return the id in the body
101
+ * (and, for proxy request logs, in the `${CACHE_ID_HEADER}` response header).
101
102
  *
102
103
  * The id is returned in the body rather than a header to keep it readable by
103
104
  * browser clients without CORS expose-header configuration; subsequent calls
@@ -134,9 +135,19 @@ class CacheControlWorker extends TerminologyWorker {
134
135
  // Flip this to default-true once all clients send an explicit value.
135
136
  const sealed = this.readSealed(params.jsonObj);
136
137
 
137
- // "<instanceCode>.<uuid>" when this server has an instance code, else a bare UUID
138
- const cacheId = cache.newCacheId();
139
- cache.set(cacheId, resources, sealed);
138
+ // "<instanceCode>.<...>" when this server has an instance code, else a bare UUID
139
+ let cacheId;
140
+ try {
141
+ cacheId = cache.start(resources, sealed);
142
+ } catch (e) {
143
+ throw this.translateCacheIssue(e);
144
+ }
145
+
146
+ // Also echoed as a response header, so that a request log at the proxy (nginx
147
+ // $sent_http_x_cache_id) can tie each issued id to the start request that got
148
+ // it - which is what replaying a captured request log needs, because a replay
149
+ // target issues different ids. The body stays the authoritative carrier (see above).
150
+ res.setHeader(CACHE_ID_HEADER, cacheId);
140
151
 
141
152
  return res.status(200).json({
142
153
  resourceType: 'Parameters',
@@ -35,6 +35,13 @@
35
35
  // name can read, add to, or reset that table. On a public server, require UUID names
36
36
  // (requireUuidNames) so that a name is effectively a secret held by the client that made it.
37
37
  //
38
+ // Shared (server.cluster.sharedClosure): other processes answer $closure on the same tables.
39
+ // The per-table lock still orders this process's calls, but a write may find the table has
40
+ // moved on since it was read: then nothing is written, and the call is worked out again
41
+ // from the table as it is now (see add, and the header of closure-store.js). After
42
+ // writeRetries such goes, the call gets the same 429 as a busy queue. The code lookups and
43
+ // subsumption tests done before are remembered for the retries, so a retry costs reads.
44
+ //
38
45
  // Cost: everything a client can make this do is bounded (see LIMITS) - the tables, the
39
46
  // concepts and entries in a table, what one call can add, what a table's configuration
40
47
  // can store, and how many calls can queue for one table and for how long. The work itself
@@ -68,6 +75,11 @@ const LIMITS = {
68
75
 
69
76
  const RESYNC_PAGE = 2000; // rows read from the store at a time
70
77
 
78
+ // shared: how many times an add is worked out again when another process changed the table
79
+ // first, and the most it waits (with jitter) before a retry
80
+ const WRITE_RETRIES = 10;
81
+ const RETRY_WAIT_MS = 50;
82
+
71
83
  class ClosureWorker extends TerminologyWorker {
72
84
  /**
73
85
  * @param {OperationContext} opContext - Operation context
@@ -219,7 +231,19 @@ class ClosureWorker extends TerminologyWorker {
219
231
  if (!table && this.store.tableCount() >= max) {
220
232
  throw this.closureIssue('too-costly', 'CLOSURE_TOO_MANY_TABLES', [String(max)], 'too-costly', 422);
221
233
  }
222
- table = this.store.createTable(name, config);
234
+ if (this.store.shared) {
235
+ // the checks above again, in the same transaction as the write
236
+ const made = this.store.createTableChecked(name, config, { reset, maxTables: max });
237
+ if (made.refused === 'exists') {
238
+ throw this.closureIssue('duplicate', 'CLOSURE_TABLE_EXISTS', [name], 'business-rule', 409);
239
+ }
240
+ if (made.refused === 'limit') {
241
+ throw this.closureIssue('too-costly', 'CLOSURE_TOO_MANY_TABLES', [String(max)], 'too-costly', 422);
242
+ }
243
+ table = made.table;
244
+ } else {
245
+ table = this.store.createTable(name, config);
246
+ }
223
247
  this.log.info(`closure table '${name}' ${reset ? 'reset' : 'created'}`);
224
248
  if (concepts.length === 0) {
225
249
  return await this.buildConceptMap(name, table.version, [], fhirVersion);
@@ -297,7 +321,9 @@ class ClosureWorker extends TerminologyWorker {
297
321
  let after = 0;
298
322
  for (;;) {
299
323
  await this.checkAndYield('closure-resync');
300
- const page = this.store.entriesPage(table.id, v, after, RESYNC_PAGE);
324
+ const page = this.store.shared
325
+ ? this.store.entriesPage(table.id, v, after, RESYNC_PAGE, table.version)
326
+ : this.store.entriesPage(table.id, v, after, RESYNC_PAGE);
301
327
  for (const r of page) {
302
328
  entries.push({
303
329
  source: { system: r.source_system, code: r.source_code, display: r.source_display },
@@ -310,6 +336,17 @@ class ClosureWorker extends TerminologyWorker {
310
336
  }
311
337
  after = page[page.length - 1].rowid;
312
338
  }
339
+ if (this.store.shared) {
340
+ // Reset/dispose deletes the old pages. Never return a truncated snapshot, or
341
+ // entries from a replacement, as though they belonged to the version we read.
342
+ const current = this.store.getTable(name);
343
+ if (!current) {
344
+ throw this.closureIssue('not-found', 'CLOSURE_TABLE_UNKNOWN', [name], 'not-found', 404);
345
+ }
346
+ if (current.id !== table.id) {
347
+ throw this.closureIssue('conflict', 'CLOSURE_TABLE_CHANGED', [name], 'business-rule', 409);
348
+ }
349
+ }
313
350
  return await this.buildConceptMap(name, table.version, entries, fhirVersion);
314
351
  }
315
352
 
@@ -320,6 +357,66 @@ class ClosureWorker extends TerminologyWorker {
320
357
  * system or code, or going over a limit, fails the whole call.
321
358
  */
322
359
  async add(table, name, conceptParams, fhirVersion) {
360
+ if (this.store.shared) {
361
+ return await this.addShared(table, name, conceptParams, fhirVersion);
362
+ }
363
+ const { fresh, entries, out } = await this.workOutAdd(table, name, conceptParams, null);
364
+ const version = this.store.recordAdd(table, fresh, entries);
365
+ return await this.addResult(name, version, fresh, entries, out, fhirVersion);
366
+ }
367
+
368
+ /**
369
+ * Shared: add, with the write checked against the table as it was read
370
+ * (recordAddChecked). If another process changed the table first, nothing was written:
371
+ * read the table again and work the add out again, up to writeRetries times, waiting a
372
+ * little (with jitter) in between; then 429, as for a busy queue. A table that is gone
373
+ * by then is unknown (404), as it would be to a call that came after the one that
374
+ * dropped it.
375
+ */
376
+ async addShared(table, name, conceptParams, fhirVersion) {
377
+ const configured = this.closureConfig.writeRetries;
378
+ const retries = Number.isInteger(configured) && configured >= 0 ? configured : WRITE_RETRIES;
379
+ let memo = null;
380
+ for (let attempt = 0; ; attempt++) {
381
+ if (!memo || memo.tableId !== table.id) {
382
+ // what's remembered holds for one table's configuration: a reset starts again
383
+ memo = { tableId: table.id, providers: new Map(), codes: new Map(), subsumes: new Map() };
384
+ }
385
+ const { fresh, entries, out } = await this.workOutAdd(table, name, conceptParams, memo);
386
+ let version = null;
387
+ try {
388
+ version = this.store.recordAddChecked(table, fresh, entries);
389
+ } catch (error) {
390
+ // the busy timeout ran out: another process held the write lock throughout
391
+ if (!String(error.code || '').startsWith('SQLITE_BUSY')) {
392
+ throw error;
393
+ }
394
+ }
395
+ if (version !== null) {
396
+ return await this.addResult(name, version, fresh, entries, out, fhirVersion);
397
+ }
398
+ if (attempt >= retries) {
399
+ this.log.info(`closure table '${name}': changed by another process on each of ${attempt + 1} tries; answering busy`);
400
+ throw this.closureIssue('too-costly', 'CLOSURE_TABLE_BUSY', [name], 'too-costly', 429);
401
+ }
402
+ this.log.info(`closure table '${name}': changed by another process since it was read; working the add out again (retry ${attempt + 1} of ${retries})`);
403
+ const wait = Math.random() * Math.min(RETRY_WAIT_MS, 5 * 2 ** attempt);
404
+ await this.opContext.waitFor(new Promise(resolve => setTimeout(resolve, wait)), 'closure-retry');
405
+ table = this.store.getTable(name);
406
+ if (!table) {
407
+ throw this.closureIssue('not-found', 'CLOSURE_TABLE_UNKNOWN', [name], 'not-found', 404);
408
+ }
409
+ }
410
+ }
411
+
412
+ /**
413
+ * Work out what adding the concepts to the table, as read, would write: the new
414
+ * concepts and the entries they make. Writes nothing.
415
+ * @param {Object} memo - shared only (else null): code lookups and subsumption tests
416
+ * already done for this table, kept across retries
417
+ * @returns {{fresh: Array, entries: Array, out: Array}}
418
+ */
419
+ async workOutAdd(table, name, conceptParams, memo) {
323
420
  // replay the table's configuration: its code systems, and its version rules
324
421
  const stored = { resourceType: 'Parameters', parameter: this.store.getParams(table.id) };
325
422
  this.setupAdditionalResources(stored);
@@ -331,7 +428,7 @@ class ClosureWorker extends TerminologyWorker {
331
428
  for (const c of existing) {
332
429
  known.add(c.system + '|' + c.code);
333
430
  }
334
- const providers = new Map();
431
+ const providers = memo ? memo.providers : new Map();
335
432
  const fresh = [];
336
433
 
337
434
  for (const p of conceptParams) {
@@ -346,20 +443,31 @@ class ClosureWorker extends TerminologyWorker {
346
443
  this.seeSourceProvider(cs, coding.system);
347
444
  providers.set(coding.system, cs);
348
445
  }
349
- const loc = await cs.locate(coding.code);
350
- if (!loc || !loc.context) {
351
- throw this.unknownCodeIssue(cs, coding.code, loc ? loc.message : null, txp);
446
+ const memoKey = memo ? coding.system + '|' + (coding.version || '') + '|' + coding.code : null;
447
+ let found = memo ? memo.codes.get(memoKey) : null;
448
+ if (!found) {
449
+ const loc = await cs.locate(coding.code);
450
+ if (!loc || !loc.context) {
451
+ throw this.unknownCodeIssue(cs, coding.code, loc ? loc.message : null, txp);
452
+ }
453
+ // the code system's own spelling of the code, so a case-insensitive code system
454
+ // doesn't end up with the same concept in the table twice
455
+ found = { context: loc.context, code: (await cs.code(loc.context)) || coding.code };
456
+ if (memo) {
457
+ memo.codes.set(memoKey, found);
458
+ }
352
459
  }
353
- // the code system's own spelling of the code, so a case-insensitive code system
354
- // doesn't end up with the same concept in the table twice
355
- const code = (await cs.code(loc.context)) || coding.code;
460
+ const code = found.code;
356
461
  const key = coding.system + '|' + code;
357
462
  if (known.has(key)) {
358
463
  continue;
359
464
  }
360
465
  known.add(key);
466
+ if (found.display === undefined) {
467
+ found.display = await cs.display(found.context);
468
+ }
361
469
  // ref: how the store refers to it until it has an id (see ClosureStore.recordAdd)
362
- fresh.push({ key, ref: key, system: coding.system, code, display: await cs.display(loc.context), cs });
470
+ fresh.push({ key, ref: key, system: coding.system, code, display: found.display, cs });
363
471
  }
364
472
 
365
473
  const maxConcepts = this.limit('maxConceptsPerTable');
@@ -374,14 +482,36 @@ class ClosureWorker extends TerminologyWorker {
374
482
  this.limit('maxEntriesPerTable') - (fresh.length > 0 ? this.store.entryCount(table.id) : 0));
375
483
  const entries = [];
376
484
  const out = [];
377
- const before = existing.map(c => ({ ref: c.id, system: c.system, code: c.code, display: c.display }));
485
+ const before = existing.map(c => ({ key: c.system + '|' + c.code, ref: c.id, system: c.system, code: c.code, display: c.display }));
486
+ // Where the provider can list a code's ancestors (CodeSystemProvider.ancestorsOf), a pair
487
+ // is related from the two ancestor sets, which costs one provider call per concept rather
488
+ // than a subsumesTest per pair. Each set is asked for once per call, when first needed,
489
+ // and only for concepts of a system that has new concepts; a concept the provider has no
490
+ // set for (null, the default) is compared by subsumesTest, as before. The sets are not
491
+ // kept between calls, so they always come from the code system as it is now.
492
+ const ancestorSets = new Map(); // key -> Set of codes, or null
493
+ const ancestorsOf = async (cs, c) => {
494
+ if (!ancestorSets.has(c.key)) {
495
+ await this.checkAndYield('closure-ancestors');
496
+ ancestorSets.set(c.key, await cs.ancestorsOf(c.code));
497
+ }
498
+ return ancestorSets.get(c.key);
499
+ };
378
500
  for (const n of fresh) {
501
+ const an = await ancestorsOf(n.cs, n);
379
502
  for (const o of before) {
380
503
  if (o.system !== n.system) {
381
504
  continue;
382
505
  }
383
506
  await this.checkAndYield('closure-subsumes');
384
- const outcome = await n.cs.subsumesTest(n.code, o.code);
507
+ const ao = an ? await ancestorsOf(n.cs, o) : null;
508
+ let outcome;
509
+ if (ao) {
510
+ // the same outcome subsumesTest gives (see ancestorsOf): n and o are different codes
511
+ outcome = ao.has(n.code) ? 'subsumes' : an.has(o.code) ? 'subsumed-by' : 'not-subsumed';
512
+ } else {
513
+ outcome = memo ? await this.memoSubsumes(memo, n, o) : await n.cs.subsumesTest(n.code, o.code);
514
+ }
385
515
  let source, target, relationship;
386
516
  if (outcome === 'subsumes') { // the new concept is the broader one
387
517
  source = o; target = n; relationship = NARROWER;
@@ -401,7 +531,20 @@ class ClosureWorker extends TerminologyWorker {
401
531
  before.push(n);
402
532
  }
403
533
 
404
- const version = this.store.recordAdd(table, fresh, entries);
534
+ return { fresh, entries, out };
535
+ }
536
+
537
+ /** n.cs.subsumesTest(n.code, o.code), remembered in the memo (shared retries). */
538
+ async memoSubsumes(memo, n, o) {
539
+ const key = n.system + '|' + n.code + '|' + o.code;
540
+ if (!memo.subsumes.has(key)) {
541
+ memo.subsumes.set(key, await n.cs.subsumesTest(n.code, o.code));
542
+ }
543
+ return memo.subsumes.get(key);
544
+ }
545
+
546
+ /** The response to an add, once it is written. */
547
+ async addResult(name, version, fresh, entries, out, fhirVersion) {
405
548
  if (fresh.length > 0) {
406
549
  this.log.info(`closure table '${name}': added ${fresh.length} concept(s), ${entries.length} entr${entries.length === 1 ? 'y' : 'ies'}, now version ${version}`);
407
550
  }
@@ -172,18 +172,21 @@ class CompareWorker extends TerminologyWorker {
172
172
 
173
173
  async doCompare(txp, thisVS, otherVS) {
174
174
 
175
- // ok, we have to compare the composes. we don't care about anything else
176
- const thisC = thisVS.jsonObj.compose;
177
- const otherC = otherVS.jsonObj.compose;
175
+ // ok, we have to compare the composes. we don't care about anything else.
176
+ // Copies: tidyIncludes merges, removes and sorts includes in place, and these value
177
+ // sets are shared - cache-id resources, and the server's own - so must not change
178
+ // (ExpansionCache.computeKey remembers a cached resource's hash, for one).
179
+ const thisC = thisVS.jsonObj.compose ? structuredClone(thisVS.jsonObj.compose) : thisVS.jsonObj.compose;
180
+ const otherC = otherVS.jsonObj.compose ? structuredClone(otherVS.jsonObj.compose) : otherVS.jsonObj.compose;
178
181
  if (!thisC) {
179
182
  return this.makeOutcome("indeterminate", `The ValueSet ${thisVS.vurl} has no compose`);
180
183
  }
181
- Extensions.checkNoModifiers(thisC, 'CompareWorker.doCompare', 'compose', thisVS.vurl)
184
+ Extensions.checkNoModifiers(thisC, 'CompareWorker.doCompare', 'ValueSet.compose', thisVS.vurl)
182
185
  this.checkNoLockedDate(thisVS.vurl, thisC);
183
186
  if (!otherC) {
184
187
  return this.makeOutcome("indeterminate", `The ValueSet ${otherVS.vurl} has no compose`);
185
188
  }
186
- Extensions.checkNoModifiers(otherC, 'CompareWorker.doCompare', 'compose', otherVS.vurl)
189
+ Extensions.checkNoModifiers(otherC, 'CompareWorker.doCompare', 'ValueSet.compose', otherVS.vurl)
187
190
  this.checkNoLockedDate(otherVS.vurl, otherC);
188
191
 
189
192
  let systems = new Map(); // tracks whether the comparison is version dependent or not