fhirsmith 0.13.4 → 0.14.1

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 (52) hide show
  1. package/CHANGELOG.md +61 -1
  2. package/README.md +16 -3
  3. package/config-template.json +81 -1
  4. package/library/cgroup-memory.js +113 -0
  5. package/library/folder-setup.js +8 -1
  6. package/library/languages.js +15 -0
  7. package/library/same-origin.js +62 -0
  8. package/nginx.md +399 -0
  9. package/package.json +3 -2
  10. package/publisher/git-remote.js +141 -0
  11. package/publisher/github-release.js +195 -0
  12. package/publisher/publisher-template.html +1 -0
  13. package/publisher/publisher.js +241 -56
  14. package/registry/registry.js +3 -1
  15. package/server.js +57 -15
  16. package/stats.js +19 -13
  17. package/testing/readme.md +144 -0
  18. package/testing/render.js +723 -0
  19. package/testing/search.js +378 -0
  20. package/testing/store.js +410 -0
  21. package/testing/testing-template.html +126 -0
  22. package/testing/testing.js +760 -0
  23. package/translations/Messages.properties +32 -0
  24. package/tx/closure/closure-store.js +396 -0
  25. package/tx/cs/cs-api.js +6 -1
  26. package/tx/cs/cs-rxnorm.js +35 -8
  27. package/tx/cs/cs-snomed.js +19 -69
  28. package/tx/html/codesystem-operations.liquid +177 -0
  29. package/tx/html/ecl-panel.liquid +93 -0
  30. package/tx/html/home-metrics.liquid +39 -76
  31. package/tx/html/tx-template.html +1 -0
  32. package/tx/library/conceptmap.js +22 -6
  33. package/tx/library/designations.js +12 -3
  34. package/tx/library/operation-outcome.js +3 -1
  35. package/tx/library/renderer.js +42 -5
  36. package/tx/operation-context.js +80 -17
  37. package/tx/provider.js +85 -0
  38. package/tx/sct/editions.js +70 -0
  39. package/tx/sct/expressions.js +128 -44
  40. package/tx/tx-html.js +426 -44
  41. package/tx/tx.js +117 -9
  42. package/tx/workers/cache-control.js +2 -2
  43. package/tx/workers/closure.js +483 -13
  44. package/tx/workers/expand.js +2 -2
  45. package/tx/workers/metadata.js +8 -3
  46. package/tx/workers/read.js +18 -3
  47. package/tx/workers/subsumes.js +18 -2
  48. package/tx/workers/translate.js +192 -21
  49. package/tx/workers/validate.js +70 -187
  50. package/tx/workers/worker.js +12 -35
  51. package/tx/xversion/xv-conceptmap.js +17 -8
  52. package/utilities/submit-test-report.js +150 -0
@@ -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;
@@ -996,8 +1059,8 @@ class OperationContext {
996
1059
  }
997
1060
  if (this._clock.clientGone) {
998
1061
  this.log(`Operation abandoned @ ${place}: client disconnected`);
999
- const error = new Issue("error", "too-costly", null,
1000
- `Operation abandoned at ${place}: the client disconnected before the response was ready`);
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);
1001
1064
  error.abandoned = true;
1002
1065
  error.diagnostics = this.diagnostics();
1003
1066
  throw error;
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) {
@@ -0,0 +1,70 @@
1
+ /**
2
+ * The SNOMED CT editions FHIRsmith knows by name - the one table, used for the provider's
3
+ * description and name (cs-snomed.js) and for the edition named in unknown-code messages
4
+ * (SCTVersion in workers/worker.js). Keyed by the edition's module id, as it appears in a
5
+ * version URI: http://snomed.info/sct/[module]/version/[date]
6
+ *
7
+ * name - human readable edition name
8
+ * code - short code used in the provider name ("SCT US")
9
+ *
10
+ * (The importer and the registry keep their own tables: those carry import settings and
11
+ * registry codes, not display names.)
12
+ */
13
+ const EDITIONS = {
14
+ '900000000000207008': { name: 'International Edition', code: 'Intl' },
15
+ // The terminology-ecosystem test distribution, published under xsct - see the
16
+ // tx-ecosystem IG, which requires a server claiming the snomed mode to load it.
17
+ '31000003106': { name: 'Test Edition', code: 'Test' },
18
+ '449081005': { name: 'International Spanish Edition', code: 'es' },
19
+ '11000221109': { name: 'Argentinian Edition', code: 'AR-es' },
20
+ '32506021000036107': { name: 'Australian Edition (with drug extension)', code: 'AU+' },
21
+ '11000234105': { name: 'Austrian Edition', code: 'AT' },
22
+ '11000172109': { name: 'Belgian Edition', code: 'BE' },
23
+ '20621000087109': { name: 'Canadian English Edition', code: 'CA-en' },
24
+ '20611000087101': { name: 'Canadian Canadian French Edition', code: 'CA-fr' },
25
+ '554471000005108': { name: 'Danish Edition', code: 'DK' },
26
+ '11000279109': { name: 'Czech Edition', code: 'CZ' },
27
+ '11000181102': { name: 'Estonian Edition', code: 'ET' },
28
+ '11000229106': { name: 'Finnish Edition', code: 'FI' },
29
+ '11000274103': { name: 'German Edition', code: 'DE' },
30
+ '1121000189102': { name: 'Indian Edition', code: 'IN' },
31
+ '827022005': { name: 'IPS Terminology', code: 'IPS' },
32
+ '11000220105': { name: 'Irish Edition', code: 'IE' },
33
+ '11000146104': { name: 'Netherlands Edition', code: 'NL' },
34
+ '21000210109': { name: 'New Zealand Edition', code: 'NZ' },
35
+ '51000202101': { name: 'Norwegian Edition', code: 'NO' },
36
+ '11000267109': { name: 'Republic of Korea Edition (South Korea)', code: 'KR' },
37
+ '900000001000122104': { name: 'Spanish National Edition', code: 'ES-es' },
38
+ '45991000052106': { name: 'Swedish Edition', code: 'SE' },
39
+ '2011000195101': { name: 'Swiss Edition', code: 'CH' },
40
+ '83821000000107': { name: 'UK Edition', code: 'UK' },
41
+ '999000021000000109': { name: 'UK Clinical Edition', code: 'UK-Clinical' },
42
+ '5631000179106': { name: 'Uruguay Edition', code: 'UY' },
43
+ '731000124108': { name: 'US Edition', code: 'US' },
44
+ '21000325107': { name: 'Chilean Edition', code: 'CL' },
45
+ '5991000124107': { name: 'US Edition (with ICD-10-CM maps)', code: 'US+' }
46
+ };
47
+
48
+ /**
49
+ * @param {string} edition - edition module id
50
+ * @returns {string|null} the edition's name, or null if it isn't a known edition
51
+ */
52
+ function editionName(edition) {
53
+ const e = EDITIONS[edition];
54
+ return e ? e.name : null;
55
+ }
56
+
57
+ /**
58
+ * @param {string} edition - edition module id
59
+ * @returns {string|null} the edition's short code, or null if it isn't a known edition
60
+ */
61
+ function editionCode(edition) {
62
+ const e = EDITIONS[edition];
63
+ return e ? e.code : null;
64
+ }
65
+
66
+ module.exports = {
67
+ EDITIONS,
68
+ editionName,
69
+ editionCode
70
+ };
@@ -26,6 +26,8 @@ const MRCM_ATTRIBUTE_RANGE_REFSET = 723562003n;
26
26
  // rules about authoring precoordinated concepts and do not constrain expressions.
27
27
  const MRCM_CONTENT_TYPE_ALL = 723596005n;
28
28
  const MRCM_CONTENT_TYPE_POSTCOORDINATED = 723594008n;
29
+ // ruleStrengthId value for an optional rule (the other value, 723597001, is mandatory).
30
+ const MRCM_RULE_STRENGTH_OPTIONAL = 723598006n;
29
31
 
30
32
  // Expression status enumeration
31
33
  const SnomedExpressionStatus = {
@@ -44,6 +46,17 @@ class SnomedExpressionBase {
44
46
  }
45
47
  }
46
48
 
49
+ /**
50
+ * Whether an attribute value has to be written in brackets: the compositional grammar
51
+ * only allows a bare conceptReference as an attribute value, so anything with more than
52
+ * one focus concept, or with refinements of its own, is a bracketed subExpression.
53
+ */
54
+ function needsBrackets(value) {
55
+ return !!value && ((value.concepts && value.concepts.length > 1) ||
56
+ (value.refinements && value.refinements.length > 0) ||
57
+ (value.refinementGroups && value.refinementGroups.length > 0));
58
+ }
59
+
47
60
  /**
48
61
  * Represents a SNOMED concept with optional code, description, literal, or decimal value
49
62
  */
@@ -161,7 +174,9 @@ class SnomedRefinement extends SnomedExpressionBase {
161
174
  * Get a string description of this refinement
162
175
  */
163
176
  describe() {
164
- return this.name.describe() + '=' + this.value.describe();
177
+ // A refined or conjoined value has to be bracketed - see attributeValue() in the parser
178
+ const v = this.value.describe();
179
+ return this.name.describe() + '=' + (needsBrackets(this.value) ? '(' + v + ')' : v);
165
180
  }
166
181
 
167
182
  /**
@@ -647,16 +662,33 @@ class SnomedExpressionParser {
647
662
 
648
663
  /**
649
664
  * Parse an attribute value
665
+ *
666
+ * The compositional grammar ABNF is
667
+ * attributeValue = expressionValue / QM stringValue QM / "#" numericValue / booleanValue
668
+ * expressionValue = ws ( conceptReference / "(" ws subExpression ws ")" ) ws
669
+ * so a value that is anything more than a single concept - a refined concept, or a
670
+ * conjunction - must be in brackets. This parser used to take an unbracketed
671
+ * sub-expression too (367430006:{405813007=85562004:272741003=24028007}), but that is not
672
+ * just non-conformant, it is ambiguous: in A:{B=C:D=E,F=G} nothing says whether F=G
673
+ * refines C or A, and the old parser silently picked C. Other parsers reject it, so we
674
+ * do too, and the renderer brackets nested values so that what we emit re-parses anywhere.
650
675
  */
651
676
  attributeValue() {
652
677
  this.ws();
653
678
 
654
679
  if (this.gchar('(')) {
655
680
  const result = this.expression();
681
+ this.ws();
656
682
  this.fixed(')');
657
683
  return result;
658
684
  } else {
659
- return this.expression();
685
+ const result = new SnomedExpression();
686
+ result.start = this.cursor;
687
+ result.concepts.push(this.concept());
688
+ this.rule(this.peek() !== ':' && this.peek() !== '+',
689
+ 'A refined or conjoined attribute value must be enclosed in brackets, e.g. 405813007=(85562004:272741003=24028007) (in "' + this.source + '")');
690
+ result.stop = this.cursor;
691
+ return result;
660
692
  }
661
693
  }
662
694
 
@@ -1625,7 +1657,7 @@ class SnomedExpressionServices {
1625
1657
  this.checkRefinement(refinement);
1626
1658
  this.checkRefinementDomain(expression, refinement);
1627
1659
  this.checkRefinementRange(refinement);
1628
- this.checkRefinementGrouped(refinement);
1660
+ this.checkRefinementGrouped(expression, refinement);
1629
1661
  }
1630
1662
  }
1631
1663
 
@@ -1644,12 +1676,8 @@ class SnomedExpressionServices {
1644
1676
 
1645
1677
  /**
1646
1678
  * Check one refinement against the MRCM attribute domain reference set: the
1647
- * focus concept the attribute is applied to must be in the attribute's domain.
1648
- *
1649
- * Every applicable rule must hold, mandatory or optional. Internationally
1650
- * there is exactly one optional rule - Laterality in the lateralizable body
1651
- * structure reference set - and it is the rule that stops laterality being
1652
- * applied to a structure that cannot be lateralized, so it is enforced.
1679
+ * focus concept the attribute is applied to must be in one of the attribute's
1680
+ * domains (see applicableDomainRules for how several rules combine).
1653
1681
  *
1654
1682
  * Attributes with no rule in this edition's MRCM are not checked, and neither
1655
1683
  * is cardinality (checkExpressionCardinality), grouping (checkRefinementGrouped)
@@ -1663,44 +1691,84 @@ class SnomedExpressionServices {
1663
1691
  if (attribute === undefined || attribute === null || attribute === NO_REFERENCE) {
1664
1692
  return;
1665
1693
  }
1666
- const rules = this.mrcmAttributeDomains().get(attribute);
1667
- if (!rules || rules.length === 0) {
1668
- // This edition's concept model says nothing about this attribute (3 active
1669
- // attributes internationally have no domain rule and appear in no
1670
- // postcoordination domain template), so there is nothing to check it against.
1671
- return;
1694
+ const result = this.applicableDomainRules(expression, attribute);
1695
+ if (result.failed) {
1696
+ const attrDesc = this.describeConceptForMessage(attribute);
1697
+ const focusDesc = result.focus.map(f => this.describeConceptForMessage(f)).join(' + ');
1698
+ throw new Error(`The SNOMED CT concept model does not allow the attribute ${attrDesc} on ${focusDesc}`
1699
+ + ` (the attribute is only valid ${result.failed})`);
1672
1700
  }
1701
+ }
1673
1702
 
1674
- // The focus may be a conjunction of concepts; the expression is a subtype of
1675
- // each of them, so the attribute is in domain if any focus concept is.
1703
+ /**
1704
+ * Work out which of an attribute's MRCM domain rules apply to an expression's
1705
+ * focus concept(s), and whether the attribute is allowed there at all.
1706
+ *
1707
+ * An attribute has one domain rule per domain it may be used in, so the
1708
+ * mandatory rules are ALTERNATIVES: |Due to| has one rule for |Clinical finding|
1709
+ * and one for |Event|, and is valid on a concept in either (issue #287 - these
1710
+ * were once ANDed, which rejected |Due to| everywhere).
1711
+ *
1712
+ * Optional rules are RESTRICTIONS layered on top: internationally the only one
1713
+ * is |Laterality| for the lateralizable body structure reference set, which
1714
+ * narrows the mandatory |Anatomical structure| rule, and it is what stops
1715
+ * laterality being applied to a structure that cannot be lateralized. So an
1716
+ * optional rule must also hold wherever a mandatory rule has admitted the
1717
+ * attribute. (An attribute with only optional rules treats them as
1718
+ * alternatives, since there is nothing for them to narrow.)
1719
+ *
1720
+ * The focus may be a conjunction of concepts; the expression is a subtype of
1721
+ * each of them, so a rule holds if it holds for any focus concept.
1722
+ *
1723
+ * @param {SnomedExpression} expression
1724
+ * @param {number} attribute - concept index of the attribute
1725
+ * @returns {{rules: Array, focus: number[], failed: string|null}} the rules that
1726
+ * apply (for cardinality and grouping - empty when the attribute is out of
1727
+ * domain), the focus concepts, and when the attribute is not allowed on this
1728
+ * focus, a description of the domain(s) it is restricted to
1729
+ */
1730
+ applicableDomainRules(expression, attribute) {
1731
+ const rules = this.mrcmAttributeDomains().get(attribute);
1676
1732
  const focus = [];
1677
1733
  for (const concept of expression.concepts) {
1678
1734
  if (concept.reference !== undefined && concept.reference !== null && concept.reference !== NO_REFERENCE) {
1679
1735
  focus.push(concept.reference);
1680
1736
  }
1681
1737
  }
1682
- if (focus.length === 0) {
1683
- return;
1738
+ if (!rules || rules.length === 0 || focus.length === 0) {
1739
+ // This edition's concept model says nothing about this attribute (3 active
1740
+ // attributes internationally have no domain rule and appear in no
1741
+ // postcoordination domain template), so there is nothing to check it against.
1742
+ return { rules: rules || [], focus, failed: null };
1684
1743
  }
1685
1744
 
1686
- // Report every rule that failed, not just the first: a laterality on a
1687
- // disorder breaks both the anatomical structure domain and the lateralizable
1688
- // refset rule, and naming only one of them misleads.
1689
- const failed = [];
1690
- for (const rule of rules) {
1691
- const ok = focus.some(f => rule.isRefset
1692
- ? this.conceptInRefSet(rule.domain, f)
1693
- : this.subsumes(rule.domain, f));
1694
- if (!ok) {
1695
- failed.push((rule.isRefset ? 'for concepts in ' : 'in the domain ') + this.describeConceptForMessage(rule.domain));
1745
+ const holds = rule => focus.some(f => rule.isRefset
1746
+ ? this.conceptInRefSet(rule.domain, f)
1747
+ : this.subsumes(rule.domain, f));
1748
+ const describe = rule => (rule.isRefset ? 'for concepts in ' : 'in the domain ')
1749
+ + this.describeConceptForMessage(rule.domain);
1750
+
1751
+ const mandatory = rules.filter(rule => !rule.optional);
1752
+ const alternatives = mandatory.length > 0 ? mandatory : rules;
1753
+ const restrictions = mandatory.length > 0 ? rules.filter(rule => rule.optional) : [];
1754
+
1755
+ const matched = alternatives.filter(holds);
1756
+ const failedRestrictions = restrictions.filter(rule => !holds(rule));
1757
+ if (matched.length === 0) {
1758
+ // Name every domain the attribute could have been used in, and any
1759
+ // restriction that also fails: a laterality on a disorder is outside both
1760
+ // the anatomical structure domain and the lateralizable refset, and naming
1761
+ // only one of them misleads.
1762
+ const parts = [alternatives.map(describe).join(', or ')];
1763
+ for (const rule of failedRestrictions) {
1764
+ parts.push(describe(rule));
1696
1765
  }
1766
+ return { rules: [], focus, failed: parts.join(', and ') };
1697
1767
  }
1698
- if (failed.length > 0) {
1699
- const attrDesc = this.describeConceptForMessage(attribute);
1700
- const focusDesc = focus.map(f => this.describeConceptForMessage(f)).join(' + ');
1701
- throw new Error(`The SNOMED CT concept model does not allow the attribute ${attrDesc} on ${focusDesc}`
1702
- + ` (the attribute is only valid ${failed.join(', and ')})`);
1768
+ if (failedRestrictions.length > 0) {
1769
+ return { rules: [], focus, failed: failedRestrictions.map(describe).join(', and ') };
1703
1770
  }
1771
+ return { rules: matched.concat(restrictions), focus, failed: null };
1704
1772
  }
1705
1773
 
1706
1774
  /**
@@ -1721,17 +1789,19 @@ class SnomedExpressionServices {
1721
1789
  * expression is not classifiable is honest, quietly guessing what they meant is not.
1722
1790
  *
1723
1791
  * As elsewhere in this check, an attribute with no rule in this edition's MRCM is
1724
- * left alone, and where several rules apply the strictest wins.
1792
+ * left alone. Only the rules for the domain(s) the focus concept is in count
1793
+ * (see applicableDomainRules), and where several of those apply the strictest wins.
1725
1794
  *
1795
+ * @param {SnomedExpression} expression - the expression the refinement is on
1726
1796
  * @param {SnomedRefinement} refinement - a refinement outside any group
1727
1797
  */
1728
- checkRefinementGrouped(refinement) {
1798
+ checkRefinementGrouped(expression, refinement) {
1729
1799
  const attribute = refinement.name ? refinement.name.reference : NO_REFERENCE;
1730
1800
  if (attribute === undefined || attribute === null || attribute === NO_REFERENCE) {
1731
1801
  return;
1732
1802
  }
1733
- const rules = this.mrcmAttributeDomains().get(attribute);
1734
- if (!rules || rules.length === 0) {
1803
+ const rules = this.applicableDomainRules(expression, attribute).rules;
1804
+ if (rules.length === 0) {
1735
1805
  return;
1736
1806
  }
1737
1807
  if (!rules.some(rule => rule.grouped)) {
@@ -1747,7 +1817,7 @@ class SnomedExpressionServices {
1747
1817
  * Built once per edition and cached; an edition with no MRCM yields an empty
1748
1818
  * map, which turns the domain check off rather than failing.
1749
1819
  *
1750
- * @returns {Map<number, Array<{domain: number, isRefset: boolean, grouped: boolean, max: number, maxInGroup: number}>>}
1820
+ * @returns {Map<number, Array<{domain: number, isRefset: boolean, grouped: boolean, optional: boolean, max: number, maxInGroup: number}>>}
1751
1821
  */
1752
1822
  mrcmAttributeDomains() {
1753
1823
  if (this._mrcmAttributeDomains) {
@@ -1762,6 +1832,7 @@ class SnomedExpressionServices {
1762
1832
  }
1763
1833
  const contentAll = this.conceptIndexOf(MRCM_CONTENT_TYPE_ALL);
1764
1834
  const contentPost = this.conceptIndexOf(MRCM_CONTENT_TYPE_POSTCOORDINATED);
1835
+ const strengthOptional = this.conceptIndexOf(MRCM_RULE_STRENGTH_OPTIONAL);
1765
1836
 
1766
1837
  // Every concept that defines a reference set, so a domain that is itself a
1767
1838
  // reference set (the lateralizable body structure refset) is tested by
@@ -1804,6 +1875,9 @@ class SnomedExpressionServices {
1804
1875
  // The grouped column is a plain 0/1 (type 4), not a reference into the string
1805
1876
  // table like the cardinalities beside it - reading it as one yields garbage.
1806
1877
  grouped: values[2] === 1,
1878
+ // An optional rule narrows a mandatory one rather than adding a domain
1879
+ // (see applicableDomainRules).
1880
+ optional: values[8] === strengthOptional,
1807
1881
  // Only the maximums are enforced. A minimum ("1..1") says a concept in
1808
1882
  // this domain must have the attribute in its own definition; it does not
1809
1883
  // say a refinement of that concept has to restate it.
@@ -2033,8 +2107,10 @@ class SnomedExpressionServices {
2033
2107
  * a single relationship group - a maximum of 0 there is how the MRCM says an
2034
2108
  * attribute is ungrouped (all 37 ungrouped attributes carry "0..0").
2035
2109
  *
2036
- * Rules are ANDed, so where an attribute has several rules the tightest
2037
- * maximum applies. Nested value expressions are checked separately, when
2110
+ * Only the rules for the domain(s) the focus concept is in count - |Procedure
2111
+ * device| is 0..* in a group on a procedure but 0..1 on an observable, and a
2112
+ * procedure must not get the observable's limit (see applicableDomainRules).
2113
+ * Where several of those rules apply, the tightest maximum wins. Nested value expressions are checked separately, when
2038
2114
  * checkRefinement recurses into them.
2039
2115
  *
2040
2116
  * @param {SnomedExpression} expression
@@ -2072,10 +2148,14 @@ class SnomedExpressionServices {
2072
2148
  perGroup.push(counts);
2073
2149
  }
2074
2150
 
2151
+ const applicable = new Map();
2075
2152
  const limit = (attribute, field) => {
2076
- const rules = domains.get(attribute);
2077
- if (!rules || rules.length === 0) {
2078
- return Infinity;
2153
+ if (!applicable.has(attribute)) {
2154
+ applicable.set(attribute, this.applicableDomainRules(expression, attribute).rules);
2155
+ }
2156
+ const rules = applicable.get(attribute);
2157
+ if (rules.length === 0) {
2158
+ return Infinity; // no rule, or out of domain (which checkRefinementDomain reports)
2079
2159
  }
2080
2160
  return rules.reduce((least, rule) => Math.min(least, rule[field]), Infinity);
2081
2161
  };
@@ -2529,7 +2609,11 @@ class SnomedExpressionServices {
2529
2609
  renderRefinement(parts, expr, option) {
2530
2610
  this.renderConcept(parts, expr.name, option);
2531
2611
  parts.push('=');
2612
+ // a refined or conjoined value must be bracketed, or no conformant parser will read it back
2613
+ const bracket = needsBrackets(expr.value);
2614
+ if (bracket) parts.push('(');
2532
2615
  this.renderExpressionParts(parts, expr.value, option);
2616
+ if (bracket) parts.push(')');
2533
2617
  }
2534
2618
 
2535
2619
  getDisplayName(conceptIdOrReference) {