fhirsmith 0.13.3 → 0.14.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 (49) hide show
  1. package/CHANGELOG.md +47 -1
  2. package/README.md +48 -4
  3. package/config-template.json +70 -1
  4. package/library/cgroup-memory.js +113 -0
  5. package/library/folder-setup.js +8 -1
  6. package/nginx.md +391 -0
  7. package/package.json +3 -2
  8. package/publisher/git-remote.js +141 -0
  9. package/publisher/github-release.js +195 -0
  10. package/publisher/publisher.js +146 -14
  11. package/server.js +51 -14
  12. package/stats.js +19 -13
  13. package/testing/readme.md +112 -0
  14. package/testing/render.js +482 -0
  15. package/testing/search.js +378 -0
  16. package/testing/store.js +266 -0
  17. package/testing/testing-template.html +126 -0
  18. package/testing/testing.js +486 -0
  19. package/translations/Messages.properties +32 -0
  20. package/tx/closure/closure-store.js +396 -0
  21. package/tx/cs/cs-api.js +6 -1
  22. package/tx/cs/cs-rxnorm.js +35 -8
  23. package/tx/cs/cs-snomed.js +142 -27
  24. package/tx/html/codesystem-operations.liquid +177 -0
  25. package/tx/html/ecl-panel.liquid +93 -0
  26. package/tx/html/home-metrics.liquid +39 -76
  27. package/tx/html/tx-template.html +1 -0
  28. package/tx/library/conceptmap.js +22 -6
  29. package/tx/library/designations.js +12 -3
  30. package/tx/library/extensions.js +2 -2
  31. package/tx/library/operation-outcome.js +3 -1
  32. package/tx/library/renderer.js +42 -5
  33. package/tx/operation-context.js +111 -15
  34. package/tx/provider.js +85 -0
  35. package/tx/sct/expressions.js +128 -44
  36. package/tx/sct/text-index.js +419 -0
  37. package/tx/tx-html.js +426 -44
  38. package/tx/tx.js +117 -9
  39. package/tx/workers/cache-control.js +2 -2
  40. package/tx/workers/closure.js +483 -13
  41. package/tx/workers/expand.js +20 -0
  42. package/tx/workers/metadata.js +29 -4
  43. package/tx/workers/read.js +18 -3
  44. package/tx/workers/subsumes.js +18 -2
  45. package/tx/workers/translate.js +167 -20
  46. package/tx/workers/validate.js +61 -182
  47. package/tx/workers/worker.js +9 -0
  48. package/tx/xversion/xv-conceptmap.js +17 -8
  49. package/utilities/submit-test-report.js +150 -0
@@ -89,6 +89,7 @@
89
89
  <a href="[%endpoint-path%]/metadata" style="color: gold">Capability Statement</a> &nbsp;|&nbsp;
90
90
  <a href="[%endpoint-path%]/metadata?mode=terminology" style="color: gold">Terminology Capabilities</a> &nbsp;|&nbsp;
91
91
  <a href="[%endpoint-path%]/op.html" style="color: gold">Operations</a> &nbsp;|&nbsp;
92
+ <a href="[%endpoint-path%]/ecl" style="color: gold">ECL</a> &nbsp;|&nbsp;
92
93
  <a href="[%endpoint-path%]/problems.html" style="color: gold">Problems</a> &nbsp;|&nbsp;
93
94
  [%library-link%]
94
95
  &nbsp;
@@ -196,6 +196,14 @@ class ConceptMap extends CanonicalResource {
196
196
  * their elements matched. group.unmapped only applies to a group that was in scope
197
197
  * for the translation, so the caller needs the same source/target test that
198
198
  * listTranslations applies - without the per-element filter.
199
+ *
200
+ * "In scope" here is only about this group's source and target code systems. It assumes
201
+ * the MAP itself is in scope, which is a separate and prior question: ConceptMap.sourceScope
202
+ * limits the map to codes that are members of that value set, and a map that does not cover
203
+ * the code has no groups in scope at all, whatever their systems say. That test needs a value
204
+ * set lookup and a $validate-code, so it lives in the worker (TranslateWorker.filterMapsByScope)
205
+ * and is applied to the map before this is ever called. Do not read a group coming back from
206
+ * here as evidence that the map applies.
199
207
  * @returns {Object[]} the matching group objects
200
208
  */
201
209
  listGroupsInScope(coding, targetScope, targetSystem) {
@@ -238,19 +246,27 @@ class ConceptMap extends CanonicalResource {
238
246
  return result;
239
247
  }
240
248
  /**
241
- * Gets the source scope (R5) or source system (R3/R4)
242
- * @returns {string|undefined} Source scope/system
249
+ * Gets the source scope (R5+) or source (R3/R4).
250
+ *
251
+ * R5 renamed ConceptMap.source[x] to ConceptMap.sourceScope[x], so an R4 resource spells this
252
+ * sourceUri / sourceCanonical and an R5 one sourceScopeUri / sourceScopeCanonical. Both have to
253
+ * be read: this server serves R4 as well as R5, and the R4 client sends the R4 spelling. Reading
254
+ * only the R5 names made this silently undefined on an R4 server, which made every map look
255
+ * unscoped - so the scope never limited anything, and $translate answered for codes outside it.
256
+ * @returns {string|undefined} Source scope
243
257
  */
244
258
  get sourceScope() {
245
- return this.jsonObj.sourceScopeUri ? this.jsonObj.sourceScopeUri : this.jsonObj.sourceScopeCanonical;
259
+ return this.jsonObj.sourceScopeUri || this.jsonObj.sourceScopeCanonical
260
+ || this.jsonObj.sourceUri || this.jsonObj.sourceCanonical;
246
261
  }
247
262
 
248
263
  /**
249
- * Gets the target scope (R5) or target system (R3/R4)
250
- * @returns {string|undefined} Target scope/system
264
+ * Gets the target scope (R5+) or target (R3/R4). See sourceScope for why both are read.
265
+ * @returns {string|undefined} Target scope
251
266
  */
252
267
  get targetScope() {
253
- return this.jsonObj.targetScopeUri ? this.jsonObj.targetScopeUri : this.jsonObj.targetScopeCanonical;
268
+ return this.jsonObj.targetScopeUri || this.jsonObj.targetScopeCanonical
269
+ || this.jsonObj.targetUri || this.jsonObj.targetCanonical;
254
270
  }
255
271
 
256
272
  /**
@@ -26,6 +26,10 @@ class SearchFilterText {
26
26
 
27
27
  this.filter = filter ? filter.toLowerCase() : null;
28
28
  this.stems = [];
29
+ // The words the stems came from, index-aligned with stems. A provider whose text
30
+ // index cannot hold a particular token (RxNorm's stem table has nothing that starts
31
+ // with a digit, for one) needs the word itself to search the text with instead.
32
+ this.words = [];
29
33
  this.stemmer = natural.PorterStemmer;
30
34
  if (filter) {
31
35
  this._process();
@@ -135,6 +139,7 @@ class SearchFilterText {
135
139
 
136
140
  _process() {
137
141
  let i = 0;
142
+ const found = [];
138
143
 
139
144
  while (i < this.filter.length) {
140
145
  if (this._isAlphaNumeric(this.filter[i])) {
@@ -142,14 +147,18 @@ class SearchFilterText {
142
147
  while (i < this.filter.length && this._isAlphaNumeric(this.filter[i])) {
143
148
  i++;
144
149
  }
145
- const word = this.filter.substring(j, i);
146
- this.stems.push(this.stemmer.stem(word.toLowerCase()));
150
+ const word = this.filter.substring(j, i).toLowerCase();
151
+ found.push({ word: word, stem: this.stemmer.stem(word) });
147
152
  } else {
148
153
  i++;
149
154
  }
150
155
  }
151
156
 
152
- this.stems.sort();
157
+ // Sorted by stem, because _find() binary searches stems - words ride along so that
158
+ // words[n] is always the word stems[n] came from.
159
+ found.sort((a, b) => (a.stem < b.stem ? -1 : (a.stem > b.stem ? 1 : 0)));
160
+ this.stems = found.map((f) => f.stem);
161
+ this.words = found.map((f) => f.word);
153
162
  }
154
163
 
155
164
  _isAlphaNumeric(char) {
@@ -24,8 +24,8 @@ const Extensions = {
24
24
  if (resource.jsonObj) {
25
25
  resource = resource.jsonObj
26
26
  }
27
- if (resource.implicitRules) {
28
- throw new Issue("error", "business-rule", null, null, 'Cannot process resource "'+name+'" due to the presence of implicit rules @'+place);
27
+ if (resource.implicitRules && 'https://hl7.org/fhir/implicit-rules/omitted-mandatory-interversion-code' != resource.implicitRules) {
28
+ throw new Issue("error", "business-rule", null, null, 'Cannot process resource "'+name+'" due to the presence of implicit rules \''+resource.implicitRules+'\' @'+place);
29
29
  }
30
30
  },
31
31
 
@@ -198,7 +198,9 @@ class OperationOutcome {
198
198
  listMissedErrors(list) {
199
199
  for (let iss of this.jsonObj.issue || []) {
200
200
  if (iss.severity === 'error' && iss.details && iss.details.text && !list.find(msg => msg === iss.details.text )) {
201
- return list.push(iss.details.text);
201
+ // every error that nothing else reported, not just the first one - an early return here
202
+ // meant the summary message named only one of them
203
+ list.push(iss.details.text);
202
204
  }
203
205
  }
204
206
 
@@ -1445,6 +1445,7 @@ class Renderer {
1445
1445
 
1446
1446
  // Analyze columns needed
1447
1447
  const columnInfo = this.analyzeExpansionColumns(vs.expansion);
1448
+ columnInfo.versions = this.expansionVersions(vs);
1448
1449
 
1449
1450
  // Build the expansion table
1450
1451
  const expTbl = x.table("codes");
@@ -1595,6 +1596,40 @@ class Renderer {
1595
1596
  /**
1596
1597
  * Add a row for an expansion contains entry
1597
1598
  */
1599
+ /**
1600
+ * The version each code system was expanded at, by system uri.
1601
+ *
1602
+ * An expansion.contains only carries a version when the expansion needed to distinguish
1603
+ * one, so a link built from the row alone would silently point at whichever version the
1604
+ * server happens to prefer - not what the reader is looking at. The expansion says which
1605
+ * version it used in its used-codesystem parameters, and a value set that came with its
1606
+ * definition says so in its compose; either will do.
1607
+ *
1608
+ * @param {Object} vs the expanded ValueSet
1609
+ * @returns {Map<string, string>} system uri to version
1610
+ */
1611
+ expansionVersions(vs) {
1612
+ const versions = new Map();
1613
+ for (const inc of vs.compose?.include || []) {
1614
+ if (inc.system && inc.version && !versions.has(inc.system)) {
1615
+ versions.set(inc.system, inc.version);
1616
+ }
1617
+ }
1618
+ for (const p of vs.expansion?.parameter || []) {
1619
+ if (p.name !== 'used-codesystem') {
1620
+ continue;
1621
+ }
1622
+ const vurl = getValuePrimitive(p);
1623
+ if (vurl && vurl.includes('|')) {
1624
+ const system = vurl.substring(0, vurl.indexOf('|'));
1625
+ if (!versions.has(system)) {
1626
+ versions.set(system, vurl.substring(vurl.indexOf('|') + 1));
1627
+ }
1628
+ }
1629
+ }
1630
+ return versions;
1631
+ }
1632
+
1598
1633
  async addExpansionRow(tbl, contains, level, columnInfo) {
1599
1634
  const tr = tbl.tr();
1600
1635
 
@@ -1617,12 +1652,14 @@ class Renderer {
1617
1652
  codeTd.tx('\u00A0'.repeat(level * 2)); // Non-breaking spaces for indentation
1618
1653
  }
1619
1654
 
1620
- // Try to link the code
1655
+ // The code links to $lookup on it - what a reader wants from a row in an expansion is
1656
+ // to find out what that code is - at the version the expansion used.
1621
1657
  if (contains.code) {
1622
- const link = this.linkResolver ?
1623
- await this.linkResolver.resolveCode(this.opContext, contains.system, contains.version, contains.code) : null;
1658
+ const version = contains.version || columnInfo.versions?.get(contains.system) || null;
1659
+ const link = this.linkResolver?.lookupLink
1660
+ ? this.linkResolver.lookupLink(contains.system, version, contains.code) : null;
1624
1661
  if (link) {
1625
- codeTd.ah(link.link).tx(contains.code);
1662
+ codeTd.ah(link, 'Look this code up').code().tx(contains.code);
1626
1663
  } else {
1627
1664
  codeTd.code().tx(contains.code);
1628
1665
  }
@@ -2732,4 +2769,4 @@ class Renderer {
2732
2769
 
2733
2770
  }
2734
2771
 
2735
- module.exports = { Renderer };
2772
+ module.exports = { Renderer, VALID_FILTER_OPS };
@@ -4,6 +4,7 @@ const crypto = require("crypto");
4
4
  const {Languages} = require("../library/languages");
5
5
  const {Issue} = require("./library/operation-outcome");
6
6
  const Logger = require("../library/logger");
7
+ const { readCgroupMemoryLimit } = require('../library/cgroup-memory');
7
8
 
8
9
  /**
9
10
  * Check if running under a debugger
@@ -110,6 +111,63 @@ class ResourceCache {
110
111
  // so a client polling $cache-control?mode=check can work out how often it needs
111
112
  // to poll instead of guessing. Null = not advertised.
112
113
  this.idleTimeoutMs = null;
114
+ // This server instance's code (modules.tx.instanceCode), prefixed onto every
115
+ // cache-id it issues as "<code>.<uuid>". A proxy in front of several instances
116
+ // routes on the prefix to get a cached request back to the one instance that
117
+ // holds the cache (see nginx.md). Null = unprefixed ids (a single server).
118
+ this.instanceCode = null;
119
+ }
120
+
121
+ /**
122
+ * Set the instance code that is prefixed onto the cache-ids this cache issues.
123
+ * 1-16 letters and digits: the code is followed by a '.', a proxy has to be able
124
+ * to match it with a simple pattern, and the whole id must stay a valid FHIR id. Empty/null/undefined means no prefix.
125
+ *
126
+ * @param {string|null|undefined} code
127
+ * @throws {Error} if the code is not letters and digits
128
+ */
129
+ setInstanceCode(code) {
130
+ this.instanceCode = ResourceCache.checkInstanceCode(code);
131
+ }
132
+
133
+ /**
134
+ * Validate an instance code from config.
135
+ * @param {string|null|undefined} code
136
+ * @returns {string|null} the code, or null for none (empty/null/undefined)
137
+ * @throws {Error} if the code is not letters and digits
138
+ */
139
+ static checkInstanceCode(code) {
140
+ if (code === undefined || code === null || code === '') {
141
+ return null;
142
+ }
143
+ // At most 16 characters: the cache-id goes back to the client as a FHIR id
144
+ // (valueId), which is capped at 64 - and "<code>." plus a 36-character UUID
145
+ // has to fit.
146
+ if (typeof code !== 'string' || !/^[A-Za-z0-9]{1,16}$/.test(code)) {
147
+ throw new Error(`tx instanceCode '${code}' is not valid: it must be 1-16 letters and digits`);
148
+ }
149
+ return code;
150
+ }
151
+
152
+ /**
153
+ * Mint a new cache-id: a UUID, prefixed with "<instanceCode>." when this server
154
+ * has an instance code. Minting only - the caller creates the entry with set().
155
+ * @returns {string}
156
+ */
157
+ newCacheId() {
158
+ const uuid = crypto.randomUUID();
159
+ return this.instanceCode ? `${this.instanceCode}.${uuid}` : uuid;
160
+ }
161
+
162
+ /**
163
+ * The instance code in a cache-id, or null if it has none. Ids this server mints
164
+ * are either a bare UUID (no '.') or "<code>.<uuid>".
165
+ * @param {string} cacheId
166
+ * @returns {string|null}
167
+ */
168
+ static issuerOf(cacheId) {
169
+ const dot = typeof cacheId === 'string' ? cacheId.indexOf('.') : -1;
170
+ return dot > 0 ? cacheId.substring(0, dot) : null;
113
171
  }
114
172
 
115
173
  /**
@@ -164,7 +222,9 @@ class ResourceCache {
164
222
 
165
223
  /**
166
224
  * The message to report for a cache-id that isn't here: which of the three
167
- * fates it met, with the numbers that make it checkable. Both throw sites
225
+ * fates it met, with the numbers that make it checkable - or, when there's no
226
+ * record of it and its prefix names another instance, that it was routed here
227
+ * by mistake. Both throw sites
168
228
  * (worker.setupAdditionalResources and batchValidate.frontLoadBatch) use this
169
229
  * so they can never drift apart.
170
230
  *
@@ -175,6 +235,15 @@ class ResourceCache {
175
235
  describeMissing(cacheId) {
176
236
  const t = this.tombstone(cacheId);
177
237
  if (!t) {
238
+ // No record of it here. If its prefix names a different instance, say so:
239
+ // that's a routing problem (proxy misconfigured, or the owning instance is
240
+ // down and the request fell through to a backup), not a lifecycle one.
241
+ const issuer = ResourceCache.issuerOf(cacheId);
242
+ if (issuer && issuer !== this.instanceCode) {
243
+ return this.instanceCode
244
+ ? { messageId: 'CACHE_ID_OTHER_INSTANCE', params: [cacheId, issuer, this.instanceCode] }
245
+ : { messageId: 'CACHE_ID_OTHER_INSTANCE_UNNAMED', params: [cacheId, issuer] };
246
+ }
178
247
  return { messageId: 'CACHE_ID_UNKNOWN', params: [cacheId] };
179
248
  }
180
249
  const ago = formatDuration(Date.now() - t.at);
@@ -752,18 +821,12 @@ class ExpansionCache {
752
821
  * Returns the byte limit, or 0 if unavailable (disables the check).
753
822
  */
754
823
  function readMemoryLimit() {
755
- try {
756
- const raw = require('fs').readFileSync('/sys/fs/cgroup/memory.max', 'utf8').trim();
757
- if (raw === 'max') return 0; // no cgroup limit
758
- return parseInt(raw);
759
- } catch {
760
- return 0; // not on Linux / no cgroup
761
- }
824
+ return readCgroupMemoryLimit().limit;
762
825
  }
763
826
 
764
827
  const MEMORY_LIMIT = readMemoryLimit();
765
828
  const MEMORY_FRACTION = 0.98;
766
- const MEMORY_THRESHOLD = MEMORY_LIMIT > 0 ? MEMORY_LIMIT * MEMORY_FRACTION : 0; // 90% of cgroup limit
829
+ const MEMORY_THRESHOLD = MEMORY_LIMIT > 0 ? MEMORY_LIMIT * MEMORY_FRACTION : 0; // MEMORY_FRACTION of cgroup limit
767
830
  const CHECK_FREQUENCY = 100;
768
831
  // How long an operation may compute without yielding the event loop (ms).
769
832
  // Node runs all JS on one thread: while a long operation executes, no other
@@ -891,8 +954,8 @@ class OperationContext {
891
954
  if (elapsed > this.timeLimit) {
892
955
  const timeInSeconds = Math.round(this.timeLimit / 1000);
893
956
  this.log(`Operation took too long @ ${place} (${this.constructor.name})`);
894
- const error = new Issue("error", "too-costly", null,
895
- `Operation exceeded time limit of ${timeInSeconds} seconds at ${place}`);
957
+ const error = new Issue("error", "too-costly", null, null,
958
+ `Operation exceeded time limit of ${timeInSeconds} seconds at ${place}`, "too-costly", 422);
896
959
  error.diagnostics = this.diagnostics();
897
960
  throw error;
898
961
  }
@@ -904,8 +967,8 @@ class OperationContext {
904
967
  const usedGB = (rss / 1024 / 1024 / 1024).toFixed(1);
905
968
  const limitGB = (MEMORY_LIMIT / 1024 / 1024 / 1024).toFixed(1);
906
969
  this.log(`Memory Limit: ${usedGB} GB of ${limitGB} GB limit @ ${place}`);
907
- const error = new Issue("error", "too-costly", null,
908
- `Operation aborted: server memory usage (${usedGB} GB) exceeds safe threshold (${MEMORY_FRACTION * 100}% of ${limitGB} GB limit) at ${place}`);
970
+ const error = new Issue("error", "too-costly", null, null,
971
+ `Operation aborted: server memory usage (${usedGB} GB) exceeds safe threshold (${MEMORY_FRACTION * 100}% of ${limitGB} GB limit) at ${place}`, "too-costly", 422);
909
972
  error.diagnostics = this.diagnostics();
910
973
  throw error;
911
974
  }
@@ -963,8 +1026,8 @@ class OperationContext {
963
1026
  this._clock.lastYield = resumed;
964
1027
  if (this._clock.clientGone) {
965
1028
  this.log(`Operation abandoned @ ${place}: client disconnected`);
966
- const error = new Issue("error", "too-costly", null,
967
- `Operation abandoned at ${place}: the client disconnected before the response was ready`);
1029
+ const error = new Issue("error", "too-costly", null, null,
1030
+ `Operation abandoned at ${place}: the client disconnected before the response was ready`, "too-costly", 422);
968
1031
  error.abandoned = true;
969
1032
  error.diagnostics = this.diagnostics();
970
1033
  throw error;
@@ -972,6 +1035,39 @@ class OperationContext {
972
1035
  }
973
1036
  }
974
1037
 
1038
+ /**
1039
+ * Await something this operation does not itself compute - typically a
1040
+ * shared structure another request is building - without charging the wait
1041
+ * to the compute deadline. The current compute slice is closed before
1042
+ * waiting and a new one opened after; aborts if the client disconnected
1043
+ * meanwhile.
1044
+ *
1045
+ * @param {Promise} promise
1046
+ * @param {string} place - Location identifier for debugging
1047
+ * @returns {Promise<*>} what the promise resolves to
1048
+ */
1049
+ async waitFor(promise, place = 'unknown') {
1050
+ const now = performance.now();
1051
+ this._clock.compute += now - this._clock.sliceStart;
1052
+ let result;
1053
+ try {
1054
+ result = await promise;
1055
+ } finally {
1056
+ const resumed = performance.now();
1057
+ this._clock.sliceStart = resumed;
1058
+ this._clock.lastYield = resumed;
1059
+ }
1060
+ if (this._clock.clientGone) {
1061
+ this.log(`Operation abandoned @ ${place}: client disconnected`);
1062
+ const error = new Issue("error", "too-costly", null, null,
1063
+ `Operation abandoned at ${place}: the client disconnected before the response was ready`, "too-costly", 422);
1064
+ error.abandoned = true;
1065
+ error.diagnostics = this.diagnostics();
1066
+ throw error;
1067
+ }
1068
+ return result;
1069
+ }
1070
+
975
1071
  unSeeAll() {
976
1072
  this.contexts = [];
977
1073
  }
package/tx/provider.js CHANGED
@@ -449,6 +449,91 @@ class Provider {
449
449
  return null;
450
450
  }
451
451
 
452
+ /**
453
+ * A checker for codes in one code system, so that a page can ask about many codes
454
+ * without paying to build the code system each time.
455
+ *
456
+ * This is for deciding whether to offer a link. A property value that came back from
457
+ * $lookup as a code is, by the definition of CodeSystem.property.type, a concept in the
458
+ * same code system - but a provider can declare a property that way and then put
459
+ * something else in it (ICD-11's classKind is chapter|block|window|category, none of
460
+ * which is an ICD-11 concept), and a link to a code that is not there is worse than no
461
+ * link. So the codes are checked, once each, against the code system itself.
462
+ *
463
+ * @param {OperationContext} opContext
464
+ * @param {string} system
465
+ * @param {string|null} version
466
+ * @returns {Promise<function(string): Promise<string|null>>|null} a function returning
467
+ * the code's display (possibly empty) if it exists and null if it does not, or null
468
+ * when this server does not have that code system at all
469
+ */
470
+ async codeChecker(opContext, system, version) {
471
+ validateParameter(opContext, "opContext", OperationContext);
472
+ if (!system) {
473
+ return null;
474
+ }
475
+ if (system.includes("|")) {
476
+ version = version || system.substring(system.indexOf("|") + 1);
477
+ system = system.substring(0, system.indexOf("|"));
478
+ }
479
+ const vurl = system + (version ? "|" + version : "");
480
+ const vurlMM = VersionUtilities.isSemVer(version) ? system + "|" + VersionUtilities.getMajMin(version) : null;
481
+
482
+ let factory = this.codeSystemFactories.get(vurl);
483
+ if (factory == null && vurlMM) {
484
+ factory = this.codeSystemFactories.get(vurlMM);
485
+ }
486
+ if (factory != null) {
487
+ const csp = await factory.build(opContext, []);
488
+ if (csp) {
489
+ opContext.registerProvider(csp);
490
+ return async (code) => {
491
+ const located = await csp.locate(code);
492
+ return located && located.context ? (await csp.display(located.context)) || '' : null;
493
+ };
494
+ }
495
+ }
496
+
497
+ let cs = this.codeSystems.get(vurl);
498
+ if (cs == null && vurlMM) {
499
+ cs = this.codeSystems.get(vurlMM);
500
+ }
501
+ if (cs != null) {
502
+ return async (code) => {
503
+ const c = cs.codeMap.get(code);
504
+ return c ? (c.display || '') : null;
505
+ };
506
+ }
507
+ return null;
508
+ }
509
+
510
+ /**
511
+ * A link to $lookup for a code on this server.
512
+ *
513
+ * resolveCode() answers "where does this code live" - a code system page, or the
514
+ * publisher's own browser - which is the right answer in prose. In an expansion the
515
+ * question is different: the reader has a list of codes in front of them and wants to
516
+ * know what one of them actually is, which is what $lookup answers, here, in the
517
+ * version the expansion used.
518
+ *
519
+ * @param {string} system
520
+ * @param {string|null} version - may also be carried on system as system|version
521
+ * @param {string} code
522
+ * @returns {string|null} the URL, or null if there is not enough to build one
523
+ */
524
+ lookupLink(system, version, code) {
525
+ if (!system || !code) {
526
+ return null;
527
+ }
528
+ if (system.includes("|")) {
529
+ version = version || system.substring(system.indexOf("|") + 1);
530
+ system = system.substring(0, system.indexOf("|"));
531
+ }
532
+ return this.path + "/CodeSystem/$lookup?system=" + encodeURIComponent(system)
533
+ + (version ? "&version=" + encodeURIComponent(version) : "")
534
+ + "&code=" + encodeURIComponent(code);
535
+ }
536
+
452
537
  async hasCsVersion(system, version) {
453
538
  for (let cs of this.codeSystems.values()) {
454
539
  if (cs.url == system && cs.version == version) {