fhirsmith 0.10.1 → 0.11.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 (53) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/changelog-template.md +22 -0
  3. package/library/logger.js +8 -1
  4. package/library/package-manager.js +40 -2
  5. package/npmprojector/watcher.js +1 -1
  6. package/package.json +3 -2
  7. package/packages/packages.js +28 -15
  8. package/publisher/publisher.js +8 -8
  9. package/registry/api.js +92 -31
  10. package/registry/crawler.js +15 -1
  11. package/registry/model.js +158 -8
  12. package/registry/readme.md +5 -13
  13. package/registry/registry.js +87 -46
  14. package/registry/test-runner.js +1 -1
  15. package/server.js +8 -1
  16. package/token/token.js +1 -1
  17. package/translations/Messages.properties +3 -1
  18. package/translations/rendering-phrases.properties +1227 -1218
  19. package/translations/rendering-phrases_ar.properties +1097 -1091
  20. package/translations/rendering-phrases_fr.properties +626 -624
  21. package/translations/rendering-phrases_nl.properties +976 -970
  22. package/translations/rendering-phrases_pt.properties +1026 -1020
  23. package/translations/rendering-phrases_ru.properties +1100 -1094
  24. package/tsconfig.eslint.json +9 -0
  25. package/tx/cs/cs-api.js +57 -4
  26. package/tx/cs/cs-cs.js +98 -25
  27. package/tx/cs/cs-hgvs.js +4 -0
  28. package/tx/cs/cs-lang.js +8 -0
  29. package/tx/cs/cs-mimetypes.js +4 -0
  30. package/tx/cs/cs-snomed.js +530 -75
  31. package/tx/cs/cs-uri.js +4 -0
  32. package/tx/data/snomed-subset.txt +40 -3
  33. package/tx/importers/import-sct.module.js +18 -0
  34. package/tx/library/designations.js +3 -5
  35. package/tx/library/renderer.js +227 -30
  36. package/tx/library.js +5 -1
  37. package/tx/operation-context.js +40 -3
  38. package/tx/provider.js +38 -9
  39. package/tx/sct/ecl.js +30 -11
  40. package/tx/sct/expressions.js +28 -1
  41. package/tx/sct/structures.js +5 -5
  42. package/tx/tx-html.js +24 -15
  43. package/tx/tx.fhir.org.yml +1 -4
  44. package/tx/vs/vs-package.js +52 -0
  45. package/tx/workers/batch-validate.js +125 -18
  46. package/tx/workers/cache-control.js +31 -2
  47. package/tx/workers/expand.js +50 -32
  48. package/tx/workers/metadata.js +27 -6
  49. package/tx/workers/search.js +7 -1
  50. package/tx/workers/validate.js +30 -18
  51. package/tx/workers/worker.js +30 -7
  52. package/tx/xversion/xv-valueset.js +5 -2
  53. package/xig/xig.js +3 -3
@@ -220,7 +220,7 @@ class ValueSetExpander {
220
220
  }
221
221
 
222
222
  async listDisplaysFromProvider(displays, cs, context) {
223
- await cs.designations(context, displays);
223
+ await cs.designations(context, displays, true);
224
224
  displays.source = cs;
225
225
  }
226
226
 
@@ -661,15 +661,9 @@ class ValueSetExpander {
661
661
  if (cs.specialEnumeration()) {
662
662
  await this.checkCanExpandValueSet(cs.specialEnumeration(), '', null);
663
663
  } else if (filter.isNull) {
664
- if (cs.isNotClosed()) {
665
- if (cs.specialEnumeration()) {
666
- Extensions.addBoolean(exp, "http://hl7.org/fhir/StructureDefinition/valueset-unclosed", true);
667
- Extensions.addString(exp, "http://hl7.org/fhir/StructureDefinition/valueset-unclosed-reason", 'The code System "' + cs.system() + " has a grammar and so has infinite members. This extension is based on " + cs.specialEnumeration());
668
- } else {
669
- throw new Issue("error", "too-costly", null, null, 'The code System "' + cs.system() + '" has a grammar, and cannot be enumerated directly', null, 422).withDiagnostics(this.worker.opContext.diagnostics());
670
- }
671
- }
672
- if (!imp && this.limitCount > 0 && cs.totalCount > this.limitCount) {
664
+ // The unclosed marking for a grammar code system is added by
665
+ // processCodes (the actual expansion); checkSource only guards size.
666
+ if (!imp && this.count !== 0 && this.limitCount > 0 && cs.totalCount > this.limitCount) {
673
667
  throw new Issue("error", "too-costly", null, 'VALUESET_TOO_COSTLY', this.worker.i18n.translate('VALUESET_TOO_COSTLY', this.params.httpLanguages, [srcURL, '>' + this.limitCount]), null, 422).withDiagnostics(this.worker.opContext.diagnostics());
674
668
  }
675
669
  }
@@ -746,45 +740,64 @@ class ValueSetExpander {
746
740
  let vs = await this.worker.findValueSet(cs.specialEnumeration(), '', null);
747
741
  const base = await this.expandValueSet(cs.specialEnumeration(), '', vs, filter, notClosed);
748
742
  Extensions.addBoolean(expansion, "http://hl7.org/fhir/StructureDefinition/valueset-unclosed", true);
749
- Extensions.addString(expansion, "http://hl7.org/fhir/StructureDefinition/valueset-unclosed-reason", 'The code System "' + cs.system() + " has a grammar and so has infinite members. This extension is based on " + cs.specialEnumeration());
743
+ Extensions.addString(expansion, "http://hl7.org/fhir/StructureDefinition/valueset-unclosed-reason", "The code System '" + cs.system() + "' has a grammar and so has infinite members. This extension is based on " + cs.specialEnumeration());
750
744
  await this.importValueSet(base, expansion, valueSets, 0);
751
745
  notClosed.value = true;
746
+ } else if (!cs.canBeExpanded()) {
747
+ throw new Issue("error", "not-supported", null, 'CODESYSTEM_NOT_ENUMERABLE', this.worker.i18n.translate('CODESYSTEM_NOT_ENUMERABLE', this.params.httpLanguages, [cs.system()]), null, 422).withDiagnostics(this.worker.opContext.diagnostics());
752
748
  } else if (filter.isNull) {
753
749
  this.worker.opContext.log('add whole code system');
754
750
  if (cs.isNotClosed()) {
755
751
  if (cs.specialEnumeration()) {
756
752
  Extensions.addBoolean(expansion, "http://hl7.org/fhir/StructureDefinition/valueset-unclosed", true);
757
- Extensions.addString(expansion, "http://hl7.org/fhir/StructureDefinition/valueset-unclosed-reason", 'The code System "' + cs.system() + " has a grammar and so has infinite members. This extension is based on " + cs.specialEnumeration());
753
+ Extensions.addString(expansion, "http://hl7.org/fhir/StructureDefinition/valueset-unclosed-reason", "The code System '" + cs.system() + "' has a grammar and so has infinite members. This extension is based on " + cs.specialEnumeration());
758
754
  } else {
759
- throw new Issue("error", "too-costly", null, null, 'The code System "' + cs.system() + '" has a grammar, and cannot be enumerated directly', null, 422).withDiagnostics(this.worker.opContext.diagnostics());
755
+ // Grammar code system with no special enumeration (e.g. SNOMED CT):
756
+ // enumerate the precoordinated concepts we can, and mark the
757
+ // expansion incomplete rather than refusing it.
758
+ Extensions.addBoolean(expansion, "http://hl7.org/fhir/StructureDefinition/valueset-unclosed", true);
759
+ Extensions.addString(expansion, "http://hl7.org/fhir/StructureDefinition/valueset-unclosed-reason", "The code System '" + cs.system() + "' has a grammar and so has infinite members");
760
760
  }
761
761
  notClosed.value = true;
762
762
  }
763
763
 
764
- const iter = await cs.iterator(null);
765
- if (valueSets.length === 0 && this.limitCount > 0 && (iter && iter.total > this.limitCount) && this.offset < 0) {
766
- throw new Issue("error", "too-costly", null, 'VALUESET_TOO_COSTLY', this.worker.i18n.translate('VALUESET_TOO_COSTLY', this.params.httpLanguages, [vsSrc.vurl, '>' + this.limitCount]), null, 422).withDiagnostics(this.worker.opContext.diagnostics());
764
+ if (this.count === 0 && filters.length === 0 && valueSets.length === 0) {
765
+ // count=0 (count only) with a single whole-code-system include and no
766
+ // additional filters: report the code count directly rather than
767
+ // enumerating - which for a poly-hierarchy (e.g. SNOMED) is both
768
+ // expensive and pointless when no codes are being materialised.
769
+ this.addToTotal(await cs.countAllCodes(excludeInactive));
770
+ } else {
771
+ const iter = await cs.iterator(null);
772
+ if (valueSets.length === 0 && this.limitCount > 0 && (iter && iter.total > this.limitCount) && this.offset < 0) {
773
+ throw new Issue("error", "too-costly", null, 'VALUESET_TOO_COSTLY', this.worker.i18n.translate('VALUESET_TOO_COSTLY', this.params.httpLanguages, [vsSrc.vurl, '>' + this.limitCount]), null, 422).withDiagnostics(this.worker.opContext.diagnostics());
767
774
 
768
- }
769
- let tcount = 0;
770
- let c = await cs.nextContext(iter);
771
- while (c) {
772
- this.worker.deadCheck('processCodes#3a');
773
- if (await this.passesFilters(cs, c, prep, filters, 0)) {
774
- tcount += await this.includeCodeAndDescendants(cs, c, expansion, valueSets, null, excludeInactive, vsSrc.vurl);
775
775
  }
776
- c = await cs.nextContext(iter);
776
+ let tcount = 0;
777
+ let c = await cs.nextContext(iter);
778
+ while (c) {
779
+ this.worker.deadCheck('processCodes#3a');
780
+ if (await this.passesFilters(cs, c, prep, filters, 0)) {
781
+ tcount += await this.includeCodeAndDescendants(cs, c, expansion, valueSets, null, excludeInactive, vsSrc.vurl);
782
+ }
783
+ c = await cs.nextContext(iter);
784
+ }
785
+ this.addToTotal(tcount);
777
786
  }
778
- this.addToTotal(tcount);
779
787
  } else {
780
788
  this.worker.opContext.log('prepare filters');
781
- this.noTotal();
782
789
  if (cs.isNotClosed(filter)) {
783
790
  notClosed.value = true;
784
791
  }
785
792
  const prep = await cs.getPrepContext(true);
786
793
  const ctxt = await cs.searchFilter(prep, filter, false);
787
794
  let set = await cs.executeFilters(prep);
795
+ if (this.count === 0 && valueSets.length === 0) {
796
+ // count=0 (count only): report the filtered size directly without
797
+ // materialising the codes.
798
+ this.addToTotal(await cs.filterSize(ctxt, set[0]));
799
+ } else {
800
+ this.noTotal();
788
801
  this.worker.opContext.log('iterate filters');
789
802
  while (await cs.filterMore(ctxt, set[0])) {
790
803
  this.worker.deadCheck('processCodes#4');
@@ -808,6 +821,7 @@ class ValueSetExpander {
808
821
  }
809
822
  }
810
823
  this.worker.opContext.log('iterate filters done');
824
+ }
811
825
  }
812
826
  }
813
827
 
@@ -856,7 +870,7 @@ class ValueSetExpander {
856
870
 
857
871
  if (cs.specialEnumeration()) {
858
872
  Extensions.addBoolean(expansion, "http://hl7.org/fhir/StructureDefinition/valueset-unclosed", true);
859
- Extensions.addString(expansion, "http://hl7.org/fhir/StructureDefinition/valueset-unclosed-reason", 'The code System "' + cs.system() + " has a grammar and so has infinite members. This extension is based on " + cs.specialEnumeration());
873
+ Extensions.addString(expansion, "http://hl7.org/fhir/StructureDefinition/valueset-unclosed-reason", "The code System '" + cs.system() + "' has a grammar and so has infinite members. This extension is based on " + cs.specialEnumeration());
860
874
  notClosed.value = true;
861
875
  }
862
876
 
@@ -867,7 +881,7 @@ class ValueSetExpander {
867
881
  throw new Issue('error', 'invalid', path + ".filter[" + i + "]", 'UNABLE_TO_HANDLE_SYSTEM_FILTER_WITH_NO_VALUE', this.worker.i18n.translate('UNABLE_TO_HANDLE_SYSTEM_FILTER_WITH_NO_VALUE', this.params.httpLanguages, [cs.system(), fc.property, fc.op]), 'vs-invalid', 400);
868
882
  }
869
883
  Extensions.checkNoModifiers(fc, 'ValueSetExpander.processCodes', 'filter', vsSrc.vurl);
870
- await cs.filter(prep, i == 0, fc.property, fc.op, fc.value);
884
+ await cs.filter(prep, i == 0, fc.property, fc.op, fc.value, vsSrc.isCached ? fc : null);
871
885
  }
872
886
 
873
887
  const fset = await cs.executeFilters(prep);
@@ -1003,9 +1017,13 @@ class ValueSetExpander {
1003
1017
  if (cs.isNotClosed(filter)) {
1004
1018
  if (cs.specialEnumeration()) {
1005
1019
  Extensions.addBoolean(expansion, "http://hl7.org/fhir/StructureDefinition/valueset-unclosed", true);
1006
- Extensions.addString(expansion, "http://hl7.org/fhir/StructureDefinition/valueset-unclosed-reason", 'The code System "' + cs.system() + " has a grammar and so has infinite members. This extension is based on " + cs.specialEnumeration());
1020
+ Extensions.addString(expansion, "http://hl7.org/fhir/StructureDefinition/valueset-unclosed-reason", "The code System '" + cs.system() + "' has a grammar and so has infinite members. This extension is based on " + cs.specialEnumeration());
1007
1021
  } else {
1008
- throw new Issue("error", "too-costly", null, null, 'The code System "' + cs.system() + '" has a grammar, and cannot be enumerated directly', null, 422).withDiagnostics(this.worker.opContext.diagnostics());
1022
+ // Grammar code system with no special enumeration (e.g. SNOMED CT):
1023
+ // enumerate the precoordinated concepts we can, and mark the
1024
+ // expansion incomplete rather than refusing it.
1025
+ Extensions.addBoolean(expansion, "http://hl7.org/fhir/StructureDefinition/valueset-unclosed", true);
1026
+ Extensions.addString(expansion, "http://hl7.org/fhir/StructureDefinition/valueset-unclosed-reason", "The code System '" + cs.system() + "' has a grammar and so has infinite members");
1009
1027
  }
1010
1028
  }
1011
1029
 
@@ -1053,14 +1071,14 @@ class ValueSetExpander {
1053
1071
  if (cs.specialEnumeration()) {
1054
1072
  await cs.specialFilter(prep, true);
1055
1073
  Extensions.addBoolean(expansion, "http://hl7.org/fhir/StructureDefinition/valueset-unclosed", true);
1056
- Extensions.addString(expansion, "http://hl7.org/fhir/StructureDefinition/valueset-unclosed-reason", 'The code System "' + cs.system() + " has a grammar and so has infinite members. This extension is based on " + cs.specialEnumeration());
1074
+ Extensions.addString(expansion, "http://hl7.org/fhir/StructureDefinition/valueset-unclosed-reason", "The code System '" + cs.system() + "' has a grammar and so has infinite members. This extension is based on " + cs.specialEnumeration());
1057
1075
  }
1058
1076
 
1059
1077
  let first = true;
1060
1078
  for (let fc of cset.filter) {
1061
1079
  this.worker.deadCheck('processCodes#4a');
1062
1080
  Extensions.checkNoModifiers(fc, 'ValueSetExpander.processCodes', 'filter', vsSrc.vurl);
1063
- await cs.filter(prep, first, fc.property, fc.op, fc.value);
1081
+ await cs.filter(prep, first, fc.property, fc.op, fc.value, vsSrc.isCached ? fc : null);
1064
1082
  first = false;
1065
1083
  }
1066
1084
 
@@ -36,16 +36,37 @@ class MetadataHandler {
36
36
 
37
37
  if (mode === 'terminology') {
38
38
  this.logInfo = 'termcaps';
39
- const tc = new TerminologyCapabilities(await this.buildTerminologyCapabilities(endpoint, provider));
39
+ const tc = new TerminologyCapabilities(await this.buildTerminologyCapabilities(endpoint, provider, req));
40
40
  return res.json(tc.jsonObj);
41
41
  }
42
42
  this.logInfo = 'metadata';
43
43
 
44
44
  // Default: return CapabilityStatement
45
- const cs = new CapabilityStatement(this.buildCapabilityStatement(endpoint, provider));
45
+ const cs = new CapabilityStatement(this.buildCapabilityStatement(endpoint, provider, req));
46
46
  return res.json(cs.jsonObj);
47
47
  }
48
48
 
49
+ /**
50
+ * Resolve the public base URL for this request.
51
+ * Derived from what the client actually asked for (scheme + host) plus the
52
+ * endpoint path, so the server echoes the host the client used rather than a
53
+ * fixed, hardcoded value. Falls back to config/host only when no request is
54
+ * available (e.g. unit tests calling the build methods directly).
55
+ * @param {express.Request} [req] - Express request
56
+ * @param {Object} endpoint - Endpoint info {path, ...}
57
+ * @returns {string} Base URL, e.g. https://tx.fhir.org/r4
58
+ */
59
+ resolveBaseUrl(req, endpoint) {
60
+ if (!req) {
61
+ return this.config.baseUrl || `https://${this.host}${endpoint.path}`;
62
+ }
63
+ // Requires app.set('trust proxy', ...) and nginx forwarding X-Forwarded-Proto
64
+ // for the scheme to be correct behind the reverse proxy.
65
+ const proto = req.protocol || 'https';
66
+ const host = req.get('host') || this.host;
67
+ return `${proto}://${host}${endpoint.path}`;
68
+ }
69
+
49
70
  /**
50
71
  * Handle GET /$versions request
51
72
  * @param {express.Request} req - Express request (with txEndpoint attached)
@@ -106,11 +127,11 @@ class MetadataHandler {
106
127
  * @param {Object} provider - Provider for code systems and resources
107
128
  * @returns {Object} CapabilityStatement resource
108
129
  */
109
- buildCapabilityStatement(endpoint) {
130
+ buildCapabilityStatement(endpoint, provider, req) {
110
131
 
111
132
  const now = new Date().toISOString();
112
133
  const fhirVersion = this.mapFhirVersion(endpoint.fhirVersion);
113
- const baseUrl = this.config.baseUrl || `https://${this.host}${endpoint.path}`;
134
+ const baseUrl = this.resolveBaseUrl(req, endpoint);
114
135
  const serverVersion = this.config.serverVersion;
115
136
 
116
137
  return {
@@ -281,9 +302,9 @@ class MetadataHandler {
281
302
  * @param {Object} provider - Provider for code systems and resources
282
303
  * @returns {Object} TerminologyCapabilities resource
283
304
  */
284
- async buildTerminologyCapabilities(endpoint, provider) {
305
+ async buildTerminologyCapabilities(endpoint, provider, req) {
285
306
  const now = new Date().toISOString();
286
- const baseUrl = this.config.baseUrl || `https://${this.host}${endpoint.path}`;
307
+ const baseUrl = this.resolveBaseUrl(req, endpoint);
287
308
  const serverVersion = this.config.serverVersion || '1.0.0';
288
309
 
289
310
  const tc = {
@@ -8,6 +8,7 @@
8
8
  const { TerminologyWorker } = require('./worker');
9
9
  const {Utilities} = require("../../library/utilities");
10
10
  const {debugLog} = require("../operation-context");
11
+ const {acceptsHtml} = require("../tx-html");
11
12
 
12
13
  class SearchWorker extends TerminologyWorker {
13
14
  /**
@@ -84,7 +85,12 @@ class SearchWorker extends TerminologyWorker {
84
85
  break;
85
86
  }
86
87
 
87
- const count = summary === 'count' ? 0 : Math.min(elements ? 2000 : 200, params._count && Utilities.isInteger(params._count) ? parseInt(params._count) : 20);
88
+ // Default page size depends on the return format: browsers get small pages
89
+ // (20), while API clients (json/xml) get big ones (1000) - they just want
90
+ // the data, and small pages make crawlers (e.g. the tx registry) painfully
91
+ // slow. An explicit _count always wins, subject to the caps.
92
+ const defaultCount = acceptsHtml(req) ? 20 : 1000;
93
+ const count = summary === 'count' ? 0 : Math.min(elements ? 2000 : 200, params._count && Utilities.isInteger(params._count) ? parseInt(params._count) : defaultCount);
88
94
  const sort = params._sort || "id";
89
95
 
90
96
  // Get matching resources
@@ -753,6 +753,7 @@ class ValueSetChecker {
753
753
  messages.push(msg);
754
754
  }
755
755
  } else {
756
+ this.worker.opContext.addNote(this.valueSet, 'Include for ' + this.worker.renderer.displayCoded(cc.system, cc.version) + ' not considered for "' + this.worker.renderer.displayCoded(system, version) + '"', this.indentCount);
756
757
  result = false;
757
758
  }
758
759
  for (let u of cc.valueSet || []) {
@@ -875,6 +876,7 @@ class ValueSetChecker {
875
876
  }
876
877
  }
877
878
  } else {
879
+ this.worker.opContext.addNote(this.valueSet, 'ValueSet has neither compose nor expansion - cannot determine membership', this.indentCount);
878
880
  result = false;
879
881
  }
880
882
  }
@@ -1126,17 +1128,23 @@ class ValueSetChecker {
1126
1128
  } else if (c.display && list.designations.length > 0) {
1127
1129
  await this.checkDisplays(list, defLang, c, msg, op, path);
1128
1130
  }
1129
- psys = c.system;
1130
- pcode = c.code;
1131
- if (ver.value) {
1132
- pver = ver.value;
1133
- }
1134
- let pd = list.preferredDisplay(this.params.workingLanguages());
1135
- if (pd) {
1136
- pdisp = pd;
1137
- }
1138
- if (!pdisp) {
1139
- pdisp = list.preferredDisplay(null);
1131
+ // Report the FIRST matching coding, not the last: when a CodeableConcept
1132
+ // has several valid codings, the code/system/version/display echoed back
1133
+ // are those of the earliest coding that validated. Only capture once
1134
+ // (pcode is undefined until the first match).
1135
+ if (pcode === undefined) {
1136
+ psys = c.system;
1137
+ pcode = c.code;
1138
+ if (ver.value) {
1139
+ pver = ver.value;
1140
+ }
1141
+ let pd = list.preferredDisplay(this.params.workingLanguages());
1142
+ if (pd) {
1143
+ pdisp = pd;
1144
+ }
1145
+ if (!pdisp) {
1146
+ pdisp = list.preferredDisplay(null);
1147
+ }
1140
1148
  }
1141
1149
  } else if (!this.params.membershipOnly && ws) {
1142
1150
  if (!isAbsoluteUrl(ws)) {
@@ -1203,7 +1211,7 @@ class ValueSetChecker {
1203
1211
  }
1204
1212
  }
1205
1213
  } else {
1206
- this.checkCanonicalStatusCS(path, op, prov, this.valueSet);
1214
+ await this.checkCanonicalStatusCS(path, op, prov, this.valueSet);
1207
1215
  let ctxt = await prov.locate(c.code);
1208
1216
  if (!ctxt.context) {
1209
1217
  // message can never be populated in pascal?
@@ -1280,7 +1288,10 @@ class ValueSetChecker {
1280
1288
  }
1281
1289
  i++;
1282
1290
  }
1283
- if (ok === false && !this.valueSet.jsonObj.internallyDefined) {
1291
+ // for an internally defined value set (CodeSystem validation), the code-system-level
1292
+ // message is preferred - but if nothing produced an error, this is the only
1293
+ // explanation there will be, so it can't be suppressed
1294
+ if (ok === false && (!this.valueSet.jsonObj.internallyDefined || !op.hasErrors())) {
1284
1295
  let mid, m, p;
1285
1296
  if (mode === 'codeableConcept') {
1286
1297
  mid = 'TX_GENERAL_CC_ERROR_MESSAGE';
@@ -1354,12 +1365,13 @@ class ValueSetChecker {
1354
1365
  if (!mpath) {
1355
1366
  mpath = 'code';
1356
1367
  }
1368
+ let mid1 = "INACTIVE_CONCEPT_FOUND";
1369
+ let mm1 = "";
1357
1370
  if (!['inactive', 'DISCOURAGED'].includes(vstatus.value)) {
1358
- let m = this.worker.i18n.translate('INACTIVE_CONCEPT_FOUND', this.params.HTTPLanguages, ['inactive', tcode]);
1359
- msg(m);
1360
- op.addIssue(new Issue('warning', 'business-rule', mpath, 'INACTIVE_CONCEPT_FOUND', m, 'code-comment'));
1371
+ mid1 = 'INACTIVE_CONCEPT_FOUND_ADD';
1372
+ mm1 = 'inactive';
1361
1373
  }
1362
- let m = this.worker.i18n.translate('INACTIVE_CONCEPT_FOUND', this.params.HTTPLanguages, [vstatus.value, tcode]);
1374
+ let m = this.worker.i18n.translate(mid1, this.params.HTTPLanguages, [vstatus.value, tcode, mm1]);
1363
1375
  msg(m);
1364
1376
  op.addIssue(new Issue('warning', 'business-rule', mpath, 'INACTIVE_CONCEPT_FOUND', m, 'code-comment'));
1365
1377
  } else if (vstatus.value && vstatus.value.toLowerCase() === 'deprecated') {
@@ -1688,7 +1700,7 @@ class ValueSetChecker {
1688
1700
  if (!fc.value) {
1689
1701
  throw new Issue('error', 'invalid', null, 'UNABLE_TO_HANDLE_SYSTEM_FILTER_WITH_NO_VALUE', this.worker.i18n.translate('UNABLE_TO_HANDLE_SYSTEM_FILTER_WITH_NO_VALUE', this.params.HTTPLanguages, [cs.system(), fc.property, fc.op]));
1690
1702
  }
1691
- await cs.filter(prep, false, fc.property, fc.op, fc.value);
1703
+ await cs.filter(prep, false, fc.property, fc.op, fc.value, (vs && vs.isCached) ? fc : null);
1692
1704
  // if (f === null) {
1693
1705
  // throw new Issue('error', 'not-supported', null, 'FILTER_NOT_UNDERSTOOD', this.worker.i18n.translate('FILTER_NOT_UNDERSTOOD', this.params.HTTPLanguages, [fc.property, fc.op, fc.value, vs.vurl, cs.system()]) + ' (2)', 'vs-invalid');
1694
1706
  // }
@@ -661,19 +661,42 @@ class TerminologyWorker {
661
661
  // specific coded issue rather than silently auto-creating a fresh cache and
662
662
  // then failing obscurely later when a by-reference resource can't be found.
663
663
  if (!this.opContext.resourceCache.has(cacheId)) {
664
+ // A comma in the cache-id means the request carried multiple X-Cache-Id
665
+ // headers (Node joins duplicate headers with ", "): server-minted ids are
666
+ // single uuids. This happens when a client attaches a new cache-id header
667
+ // without clearing the old one (e.g. IG publisher builds where a second
668
+ // client context wrapped the same HTTP client) - report that specifically,
669
+ // because the generic "unknown cache" message sends people looking for an
670
+ // expiry problem that isn't there.
671
+ if (cacheId.includes(',')) {
672
+ throw new Issue('error', 'invalid', null, 'CACHE_ID_DUPLICATE',
673
+ `The request carried multiple ${CACHE_ID_HEADER} headers (combined value '${cacheId}'). ` +
674
+ 'This is a client error: each request must carry exactly one cache-id. It usually means the client ' +
675
+ 'started a second cache on a connection that already had one, without removing the first header.',
676
+ 'cache-id-duplicate', 400);
677
+ }
664
678
  throw new Issue('error', 'not-found', null, 'CACHE_ID_UNKNOWN',
665
679
  this.i18n.translate('CACHE_ID_UNKNOWN', this.opContext.langs, [cacheId]),
666
680
  'cache-id-unknown', 404);
667
681
  }
668
682
 
669
- // The cache exists: merge any resources supplied on this request into it
670
- // (incremental population is allowed), then expose the full cache contents.
671
- const toCache = txResources.concat(primaryResources);
672
- if (toCache.length > 0) {
673
- this.opContext.resourceCache.add(cacheId, toCache);
683
+ // The cache exists. If it is unsealed, merge any resources supplied on this
684
+ // request into it (incremental population). If it is sealed, the cache is
685
+ // fixed at what it was created with: resources supplied now are still used
686
+ // for this request (via additionalResources below) but are NOT added to the
687
+ // shared cache, so a sealed cache never grows.
688
+ if (!this.opContext.resourceCache.isSealed(cacheId)) {
689
+ const toCache = txResources.concat(primaryResources);
690
+ if (toCache.length > 0) {
691
+ this.opContext.resourceCache.add(cacheId, toCache);
692
+ }
693
+ this.additionalResources = this.opContext.resourceCache.get(cacheId);
694
+ } else {
695
+ // Sealed: expose the cache contents plus this request's own inline
696
+ // resources (used for this call only), without mutating the cache.
697
+ this.additionalResources = this.opContext.resourceCache.get(cacheId)
698
+ .concat(txResources, primaryResources);
674
699
  }
675
-
676
- this.additionalResources = this.opContext.resourceCache.get(cacheId);
677
700
  this.additionalResourcesCacheId = cacheId;
678
701
  } else {
679
702
  // No cache-id, just use the tx-resources directly
@@ -131,9 +131,12 @@ function valueSetR5ToR4(r5Obj) {
131
131
  });
132
132
  }
133
133
  delete exp.property;
134
- convertContainsPropertyR5ToR4(exp.contains);
135
-
136
134
  }
135
+
136
+ // ValueSet.expansion.contains.property is R5-only too, and must be converted
137
+ // whether or not the expansion declares expansion-level property definitions:
138
+ // a contained code can carry a property with no expansion.property present.
139
+ convertContainsPropertyR5ToR4(exp.contains);
137
140
  }
138
141
 
139
142
  return r5Obj;
package/xig/xig.js CHANGED
@@ -977,7 +977,7 @@ async function fetchResourceRows(queryParams, offset = 0, limit = 200) {
977
977
  }
978
978
 
979
979
  function rowToObject(row, queryParams) {
980
- const { ver, realm, auth, type, rt } = queryParams;
980
+ const { type } = queryParams;
981
981
  const packageObj = getPackage(row.PackageKey);
982
982
 
983
983
  const obj = {
@@ -3156,7 +3156,7 @@ async function initializeXigModule(stats, xigConfig) {
3156
3156
  if (!fs.existsSync(XIG_DB_PATH)) {
3157
3157
  xigLog.info('No existing XIG database found, triggering initial download');
3158
3158
  setTimeout(() => {
3159
- updateXigDatabase();
3159
+ updateXigDatabase().catch((err) => xigLog.error('XIG database update failed: ' + err.message));
3160
3160
  }, 5000);
3161
3161
  }
3162
3162
 
@@ -3167,7 +3167,7 @@ async function initializeXigModule(stats, xigConfig) {
3167
3167
  // Note: This assumes we're called only when XIG is enabled
3168
3168
  if (xigConfig?.autoUpdate !== false) {
3169
3169
  cron.schedule('0 2 * * *', () => {
3170
- updateXigDatabase();
3170
+ updateXigDatabase().catch((err) => xigLog.error('XIG database update failed: ' + err.message));
3171
3171
  });
3172
3172
  }
3173
3173