fhirsmith 0.10.0 → 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 (55) hide show
  1. package/CHANGELOG.md +48 -1
  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 +235 -38
  36. package/tx/library.js +5 -1
  37. package/tx/operation-context.js +40 -3
  38. package/tx/provider.js +41 -12
  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-database.js +41 -8
  45. package/tx/vs/vs-package.js +55 -0
  46. package/tx/vs/vs-vsac.js +4 -0
  47. package/tx/workers/batch-validate.js +125 -18
  48. package/tx/workers/cache-control.js +31 -2
  49. package/tx/workers/expand.js +50 -32
  50. package/tx/workers/metadata.js +27 -6
  51. package/tx/workers/search.js +7 -1
  52. package/tx/workers/validate.js +30 -18
  53. package/tx/workers/worker.js +30 -7
  54. package/tx/xversion/xv-valueset.js +5 -2
  55. package/xig/xig.js +3 -3
@@ -17,6 +17,17 @@ const {ECLLexer, ECLParser, ECLNodeType, ECLTokenType} = require("../sct/ecl");
17
17
  const {Issue} = require("../library/operation-outcome");
18
18
  const {debugLog} = require("../operation-context");
19
19
 
20
+ // Symbol key under which a resolved ECL filter analysis is memoised directly on
21
+ // the ValueSet compose.include.filter element (`fc`). Symbol-keyed and defined
22
+ // non-enumerable, so it never serialises, clones, or spreads into a response;
23
+ // it lives and dies with the filter element (and thus the cached ValueSet).
24
+ // Value: Map(codeSystemUrl|version -> {descendants, members, matches, eclAst}).
25
+ // SNOMED editions vary the url (and always have a version), so both url and
26
+ // version are part of the key. Only iteration-INSENSITIVE ECL is cached (see
27
+ // _cacheEcl): a wildcard's materialised form depends on forIteration and is not
28
+ // shared - it simply recomputes.
29
+ const SNOMED_FILTER_ANALYSIS = Symbol('snomedFilterAnalysis');
30
+
20
31
  // Context kinds matching Pascal enum
21
32
  const SnomedProviderContextKind = {
22
33
  CODE: 0,
@@ -199,6 +210,21 @@ class SnomedServices {
199
210
  }
200
211
  }
201
212
 
213
+ // Count of active concepts (status nibble == 0), computed once and cached.
214
+ // Concepts are fixed-size records addressed by byte offset, so iterate by
215
+ // ordinal via getConceptByCount. Used by the count=0 expansion fast path.
216
+ activeConceptCount() {
217
+ if (this._activeConceptCount != null) return this._activeConceptCount;
218
+ let n = 0;
219
+ const total = this.concepts.count();
220
+ for (let ord = 0; ord < total; ord++) {
221
+ const c = this.concepts.getConceptByCount(ord);
222
+ if ((c.flags & 0x0F) === 0) n++;
223
+ }
224
+ this._activeConceptCount = n;
225
+ return n;
226
+ }
227
+
202
228
  isPrimitive(reference) {
203
229
  try {
204
230
  const concept = this.concepts.getConcept(reference);
@@ -223,12 +249,109 @@ class SnomedServices {
223
249
  }
224
250
 
225
251
  const descendants = this.refs.getReferences(closureRef);
226
- return descendants && descendants.includes(childRef);
252
+ if (!descendants) {
253
+ return false;
254
+ }
255
+ // The closure array is stored sorted ascending (see the importer's
256
+ // buildConceptClosure), so membership is a binary search rather than a
257
+ // linear scan - this is the hot path for is-a and ECL refinement checks.
258
+ let lo = 0;
259
+ let hi = descendants.length - 1;
260
+ while (lo <= hi) {
261
+ const mid = (lo + hi) >> 1;
262
+ const v = descendants[mid];
263
+ if (v === childRef) {
264
+ return true;
265
+ } else if (v < childRef) {
266
+ lo = mid + 1;
267
+ } else {
268
+ hi = mid - 1;
269
+ }
270
+ }
271
+ return false;
227
272
  } catch (error) {
228
273
  return false;
229
274
  }
230
275
  }
231
276
 
277
+ // Key concept indexes used for preferred-term selection, resolved once per
278
+ // loaded edition. US English (900000000000509007) is the global default
279
+ // language reference set for display.
280
+ _displayConstants() {
281
+ if (this._dispConst) return this._dispConst;
282
+ const idx = (id) => { const r = this.concepts.findConcept(id); return r.found ? r.index : -1; };
283
+ this._dispConst = {
284
+ usRefset: idx(900000000000509007n), // US English language reference set
285
+ preferred: idx(900000000000548007n), // Preferred
286
+ synonym: idx(900000000000013009n), // Synonym
287
+ fsn: idx(900000000000003001n) // Fully specified name
288
+ };
289
+ return this._dispConst;
290
+ }
291
+
292
+ // Ordered display language reference set(s) for the loaded edition. SNOMED's
293
+ // default display language differs by edition, and a concept can carry a
294
+ // preferred synonym in more than one dialect at once (the International
295
+ // edition ships both US English 509007 and GB English 508004, each marking a
296
+ // different synonym Preferred). The edition default decides which term is
297
+ // shown. Editions not listed here fall back to "preferred synonym in any
298
+ // language refset" (the historical behaviour), so no edition regresses.
299
+ _displayRefsetOrder() {
300
+ if (this._dispOrder) return this._dispOrder;
301
+ const idx = (id) => { const r = this.concepts.findConcept(id); return r.found ? r.index : -1; };
302
+ const US = 900000000000509007n; // US English
303
+ const GB = 900000000000508004n; // GB English
304
+ const byEdition = {
305
+ '900000000000207008': [US], // International Edition (US English is the default)
306
+ '31000003106': [US], // tx-ecosystem test edition (International-derived, US English default)
307
+ '731000124108': [US], // US Edition
308
+ '5991000124107': [US], // US Edition (with ICD-10-CM maps)
309
+ '83821000000107': [GB], // UK Edition
310
+ '999000021000000109': [GB] // UK Clinical Edition
311
+ };
312
+ const ids = byEdition[String(this.edition)] || [];
313
+ this._dispOrder = ids.map(idx).filter((i) => i >= 0);
314
+ return this._dispOrder;
315
+ }
316
+
317
+ // Acceptability (a concept index, e.g. Preferred/Acceptable) of a description
318
+ // within the given language reference set, or -1 if it is not a member.
319
+ _descAcceptability(description, refsetIndex) {
320
+ if (!description.refsets || !description.valueses) return -1;
321
+ const refsets = this.refs.getReferences(description.refsets);
322
+ const valueses = this.refs.getReferences(description.valueses);
323
+ if (!refsets || !valueses) return -1;
324
+ for (let i = 0; i < refsets.length; i++) {
325
+ if (refsets[i] === refsetIndex) {
326
+ const vals = this.refs.getReferences(valueses[i]); // [acceptabilityIndex, type]
327
+ return (vals && vals.length > 0) ? vals[0] : -1;
328
+ }
329
+ }
330
+ return -1;
331
+ }
332
+
333
+ // True if the description is a synonym marked Preferred (900000000000548007)
334
+ // in ANY of its language reference sets. Editions use different English
335
+ // language refsets (International US-English 509007, GB 508004, US extension,
336
+ // ...), so we accept a preferred marking in any of them rather than a single
337
+ // hard-coded refset. (Multi-language dialect selection is a separate concern.)
338
+ _synonymIsPreferred(description) {
339
+ const K = this._displayConstants();
340
+ if (description.kind !== K.synonym) return false;
341
+ if (!description.valueses) return false;
342
+ const valueses = this.refs.getReferences(description.valueses);
343
+ if (!valueses) return false;
344
+ for (let i = 0; i < valueses.length; i++) {
345
+ const vals = this.refs.getReferences(valueses[i]); // [acceptabilityIndex, type]
346
+ if (vals && vals.length > 0 && vals[0] === K.preferred) return true;
347
+ }
348
+ return false;
349
+ }
350
+
351
+ // Return a concept's display: the synonym marked Preferred in the US English
352
+ // language reference set; failing that the FSN; failing that the first active
353
+ // description. Previously this returned the first active description outright,
354
+ // which is only the preferred term by accident of import order.
232
355
  getDisplayName(reference = 0) {
233
356
  try {
234
357
  const concept = this.concepts.getConcept(reference);
@@ -239,17 +362,40 @@ class SnomedServices {
239
362
  }
240
363
 
241
364
  const descriptionIndices = this.refs.getReferences(descriptionsRef);
365
+ const K = this._displayConstants();
242
366
 
243
- // Look for preferred term, then any active description
367
+ // 1. Preferred synonym in the edition's default display refset(s), tried
368
+ // in priority order. This is what disambiguates dialects: on the
369
+ // International edition a concept may be Preferred in both US and GB
370
+ // English, and the edition default (US English) must win.
371
+ for (const refsetIdx of this._displayRefsetOrder()) {
372
+ for (const descIndex of descriptionIndices) {
373
+ const description = this.descriptions.getDescription(descIndex);
374
+ if (!description.active || description.kind !== K.synonym) continue;
375
+ if (this._descAcceptability(description, refsetIdx) === K.preferred) {
376
+ return this.strings.getEntry(description.iDesc).trim();
377
+ }
378
+ }
379
+ }
380
+
381
+ // 2. Fallback: preferred synonym in ANY language refset; then FSN; then
382
+ // the first active description. Used for editions without a mapped
383
+ // default refset, and for concepts with no preferred synonym there.
384
+ let fsnTerm = '';
385
+ let firstActive = '';
244
386
  for (const descIndex of descriptionIndices) {
245
387
  const description = this.descriptions.getDescription(descIndex);
246
- if (description.active) {
247
- const term = this.strings.getEntry(description.iDesc);
248
- return term.trim();
388
+ if (!description.active) continue;
389
+ const term = this.strings.getEntry(description.iDesc).trim();
390
+ if (firstActive === '') firstActive = term;
391
+ if (this._synonymIsPreferred(description)) {
392
+ return term; // preferred synonym (any English language refset)
393
+ }
394
+ if (description.kind === K.fsn && fsnTerm === '') {
395
+ fsnTerm = term;
249
396
  }
250
397
  }
251
-
252
- return '';
398
+ return fsnTerm || firstActive || '';
253
399
  } catch (error) {
254
400
  return '';
255
401
  }
@@ -503,7 +649,15 @@ class SnomedServices {
503
649
  result = this._evalECLNode(ast, opContext);
504
650
  } catch (err) {
505
651
  debugLog(err);
506
- throw new Issue('error', 'invalid', null, 'UNSUPPORTED_ECL', opContext.i18n.translate('UNSUPPORTED_ECL', opContext.langs, [eclExpression, err.message]), 'vs-invalid').handleAsOO(400);
652
+ // Distinguish an *invalid* ECL expression (a malformed SCTID, or a
653
+ // reference to a concept that does not exist - the expression can never
654
+ // be valid) from an *unsupported* one (a well-formed expression using a
655
+ // feature this server does not implement). The tx-ecosystem test suite
656
+ // expects INVALID_ECL for the former, UNSUPPORTED_ECL for the latter.
657
+ const emsg = (err && err.message) ? err.message : String(err);
658
+ const invalid = /is not known/.test(emsg) || /Cannot convert .* to a BigInt/i.test(emsg);
659
+ const msgId = invalid ? 'INVALID_ECL' : 'UNSUPPORTED_ECL';
660
+ throw new Issue('error', 'invalid', null, msgId, opContext.i18n.translate(msgId, opContext.langs, [eclExpression, err.message]), 'vs-invalid').handleAsOO(400);
507
661
  }
508
662
  // Wildcard + iteration: the `eclWildcard` flag is only consulted by the
509
663
  // per-concept membership checks (filterCheck/filterLocate). For an $expand
@@ -512,7 +666,13 @@ class SnomedServices {
512
666
  if (forIteration && result.eclWildcard && (!result.descendants || result.descendants.length === 0)) {
513
667
  result.descendants = this._eclEnumerateActiveConcepts(opContext);
514
668
  delete result.eclWildcard;
669
+ // The materialised wildcard form differs from the lazy (eclWildcard) form,
670
+ // so mark it: the filter cache must not share it across iteration modes.
671
+ result.iterationSensitive = true;
515
672
  }
673
+ // Keep the parsed AST so validate-code can test a post-coordinated
674
+ // expression's membership against the constraint (see _eclExpressionSatisfies).
675
+ result.eclAst = ast;
516
676
  return result;
517
677
  };
518
678
 
@@ -522,12 +682,24 @@ class SnomedServices {
522
682
  * @returns {number[]}
523
683
  */
524
684
  _eclEnumerateActiveConcepts = function (opContext) {
685
+ // The wildcard `*` enumerates the SNOMED CT concept hierarchy: the root
686
+ // 138875005 |SNOMED CT Concept| and its descendants. This excludes the
687
+ // foundation-metadata concepts that appear in a subset's concept table
688
+ // (referenced by the RF2 files as module / type / acceptability ... ids)
689
+ // without their is-a relationships, so they are not reachable from the root
690
+ // and are not returned by `*` (matching Snowstorm). On a full edition every
691
+ // real concept is under the root, so nothing is lost.
692
+ const rootRes = this.concepts.findConcept(138875005n);
693
+ if (rootRes && rootRes.found) {
694
+ return this.filterIsA(138875005n, true).descendants;
695
+ }
696
+ // Fallback (no recognisable root): every active concept.
525
697
  const all = [];
526
698
  const n = this.concepts.count();
527
699
  for (let i = 0; i < n; i++) {
528
700
  if (opContext) opContext.deadCheck('ecl:enumerateActiveConcepts');
529
701
  const concept = this.concepts.getConceptByCount(i);
530
- if ((concept.flags & 0x0F) === 0) { // active
702
+ if ((concept.flags & 0x0F) === 0) {
531
703
  all.push(concept.index);
532
704
  }
533
705
  }
@@ -700,8 +872,9 @@ class SnomedServices {
700
872
  * resolved to a set of candidate reference-set concepts, and the result is the
701
873
  * union of their active concept members' referenced components.
702
874
  *
703
- * When the operand is a bare, explicitly-named concept that is not a reference
704
- * set, this throws (the caller asked for an error in that case). When the
875
+ * When the operand concept is not a reference set the result is empty (a
876
+ * non-refset concept simply has no members - this matches Snowstorm and the
877
+ * tx-ecosystem test suite). When the
705
878
  * operand is a computed expression (e.g. ^(<<900000000000455006)), concepts in
706
879
  * the resolved set that are not reference sets are simply skipped — the closure
707
880
  * of "Reference set" necessarily includes the non-refset parent itself.
@@ -711,7 +884,6 @@ class SnomedServices {
711
884
  * @returns {SnomedFilterContext}
712
885
  */
713
886
  _evalMemberOf = function (memberOfNode, opContext) {
714
- const operandIsBareRef = memberOfNode.refSet.type === ECLNodeType.CONCEPT_REFERENCE;
715
887
  const refsetConcepts = this._eclResolveSet(this._evalECLNode(memberOfNode.refSet, opContext), opContext);
716
888
 
717
889
  const members = new Set();
@@ -719,11 +891,10 @@ class SnomedServices {
719
891
  if (opContext) opContext.deadCheck('ecl:memberOf');
720
892
  const membersRef = this._findRefSetMembersRef(refsetIdx);
721
893
  if (membersRef === null) {
722
- if (operandIsBareRef) {
723
- const code = this.concepts.getConceptId(refsetIdx).toString();
724
- throw new Error(`The SNOMED CT Concept ${code} is not a reference set`);
725
- }
726
- continue; // computed operand: ignore concepts that aren't reference sets
894
+ // Operand concept is not a reference set: yield no members for it
895
+ // (empty result) rather than erroring. Applies to both a bare concept
896
+ // reference (e.g. "^ 10200004") and a computed operand.
897
+ continue;
727
898
  }
728
899
  if (membersRef === 0 || membersRef === 0xFFFFFFFF) {
729
900
  continue; // a reference set with no members
@@ -817,7 +988,7 @@ class SnomedServices {
817
988
  const matching = [];
818
989
  for (const conceptIdx of baseSet) {
819
990
  if (opContext) opContext.deadCheck('ecl:refined');
820
- if (this._refinementMatches(conceptIdx, node.refinement, opContext)) {
991
+ if (this._refinementMatches(this._conceptRelRecords(conceptIdx), node.refinement, opContext)) {
821
992
  matching.push(conceptIdx);
822
993
  }
823
994
  }
@@ -833,17 +1004,17 @@ class SnomedServices {
833
1004
  * @param {object} refinement
834
1005
  * @returns {boolean}
835
1006
  */
836
- _refinementMatches = function (conceptIdx, refinement, opContext) {
1007
+ _refinementMatches = function (rels, refinement, opContext) {
837
1008
  switch (refinement.type) {
838
1009
  case ECLNodeType.ATTRIBUTE:
839
- return this._attributeMatches(conceptIdx, refinement, null, opContext);
1010
+ return this._attributeMatches(rels, refinement, null, opContext);
840
1011
  case ECLNodeType.ATTRIBUTE_SET:
841
1012
  for (const a of refinement.attributes) {
842
- if (!this._refinementMatches(conceptIdx, a, opContext)) return false;
1013
+ if (!this._refinementMatches(rels, a, opContext)) return false;
843
1014
  }
844
1015
  return true;
845
1016
  case ECLNodeType.ATTRIBUTE_GROUP:
846
- return this._attributeGroupMatches(conceptIdx, refinement, opContext);
1017
+ return this._attributeGroupMatches(rels, refinement, opContext);
847
1018
  default:
848
1019
  throw new Error(`Unsupported refinement node type: ${refinement.type}`);
849
1020
  }
@@ -859,7 +1030,7 @@ class SnomedServices {
859
1030
  * @param {number|null} groupFilter
860
1031
  * @returns {boolean}
861
1032
  */
862
- _attributeMatches = function (conceptIdx, attr, groupFilter, opContext) {
1033
+ _attributeMatches = function (rels, attr, groupFilter, opContext) {
863
1034
  if (attr.reverse) {
864
1035
  throw new Error('ECL reverse attributes (R) are not yet supported');
865
1036
  }
@@ -876,7 +1047,7 @@ class SnomedServices {
876
1047
  throw new Error('ECL refinements only support plain concept-reference attribute names');
877
1048
  }
878
1049
 
879
- const count = this._countAttributeMatches(conceptIdx, attr, groupFilter, opContext);
1050
+ const count = this._countAttributeMatches(rels, attr, groupFilter, opContext);
880
1051
 
881
1052
  if (attr.cardinality) {
882
1053
  return this._cardinalityAccepts(attr.cardinality, count);
@@ -893,7 +1064,7 @@ class SnomedServices {
893
1064
  * @param {number|null} groupFilter
894
1065
  * @returns {number}
895
1066
  */
896
- _countAttributeMatches = function (conceptIdx, attr, groupFilter, opContext) {
1067
+ _countAttributeMatches = function (rels, attr, groupFilter, opContext) {
897
1068
  // The attribute type and the value set depend only on the (static) AST node,
898
1069
  // not on the concept being tested, so resolve them once per refinement
899
1070
  // attribute and reuse across the whole base set. Without this memo the value
@@ -916,21 +1087,23 @@ class SnomedServices {
916
1087
  const attrTypeIdx = resolved.attrTypeIdx;
917
1088
  const valueSet = resolved.valueSet;
918
1089
 
919
- const relIdxs = this.getConceptRelationships(conceptIdx);
920
- // ECL cardinality counts non-redundant matching attributes — i.e. distinct
921
- // matching values — not raw relationship rows. The same (type, value) can
922
- // appear in multiple role groups; counting rows over-counts and makes e.g.
923
- // [1..1] fail and [2..*] succeed for a concept with a single morphology.
924
- const matchedTargets = new Set();
925
- for (const relIdx of relIdxs) {
1090
+ // ECL cardinality counts OCCURRENCES of a matching attribute, per role group
1091
+ // — not distinct values. ECL runs against the necessary normal form, where a
1092
+ // role group has no redundant attributes, so the same (type, value) in two
1093
+ // different role groups counts as two occurrences (confirmed by the SNOMED
1094
+ // Computable Languages Group; ECL 6.3 "non-redundant" refers to the normal
1095
+ // form, not to de-duplicating across groups). We therefore count distinct
1096
+ // (group, target) pairs: within a group filter this is the matching values
1097
+ // in that group; ungrouped it is the total occurrences across all groups.
1098
+ const matched = new Set();
1099
+ for (const rel of rels) {
926
1100
  if (opContext) opContext.deadCheck('ecl:countAttributeMatches');
927
- const rel = this.relationships.getRelationship(relIdx);
928
1101
  if (!rel.active) continue;
929
1102
  if (rel.relType !== attrTypeIdx) continue;
930
1103
  if (groupFilter !== null && rel.group !== groupFilter) continue;
931
- if (valueSet.has(rel.target)) matchedTargets.add(rel.target);
1104
+ if (valueSet.has(rel.target)) matched.add(rel.group + ':' + rel.target);
932
1105
  }
933
- return matchedTargets.size;
1106
+ return matched.size;
934
1107
  };
935
1108
 
936
1109
  /**
@@ -958,11 +1131,9 @@ class SnomedServices {
958
1131
  * @param {object} group
959
1132
  * @returns {boolean}
960
1133
  */
961
- _attributeGroupMatches = function (conceptIdx, group, opContext) {
962
- const relIdxs = this.getConceptRelationships(conceptIdx);
1134
+ _attributeGroupMatches = function (rels, group, opContext) {
963
1135
  const groupNumbers = new Set();
964
- for (const relIdx of relIdxs) {
965
- const rel = this.relationships.getRelationship(relIdx);
1136
+ for (const rel of rels) {
966
1137
  if (rel.active && rel.group > 0) {
967
1138
  groupNumbers.add(rel.group);
968
1139
  }
@@ -973,7 +1144,7 @@ class SnomedServices {
973
1144
  if (opContext) opContext.deadCheck('ecl:attributeGroup');
974
1145
  let allMatch = true;
975
1146
  for (const attr of group.attributes) {
976
- if (!this._attributeMatches(conceptIdx, attr, g, opContext)) {
1147
+ if (!this._attributeMatches(rels, attr, g, opContext)) {
977
1148
  allMatch = false;
978
1149
  break;
979
1150
  }
@@ -991,6 +1162,139 @@ class SnomedServices {
991
1162
  return false;
992
1163
  };
993
1164
 
1165
+ // Materialise a concept's relationships as plain records {active, relType,
1166
+ // target, group} — the shape the refinement matchers consume. Sharing this
1167
+ // shape lets the same matchers run against a post-coordinated expression's
1168
+ // relationships (see _expressionRelRecords).
1169
+ _conceptRelRecords = function (conceptIdx) {
1170
+ const relIdxs = this.getConceptRelationships(conceptIdx);
1171
+ const out = [];
1172
+ for (const relIdx of relIdxs) {
1173
+ out.push(this.relationships.getRelationship(relIdx));
1174
+ }
1175
+ return out;
1176
+ };
1177
+
1178
+ // Turn one parsed expression refinement (name = attribute concept, value =
1179
+ // sub-expression) into a relationship record in the given group, or null if it
1180
+ // is not a plain concept = concept pair.
1181
+ _refinementToRel = function (refinement, group) {
1182
+ const relType = (refinement.name && refinement.name.reference != null)
1183
+ ? refinement.name.reference : NO_REFERENCE;
1184
+ let target = NO_REFERENCE;
1185
+ const val = refinement.value;
1186
+ if (val && val.concepts && val.concepts.length > 0) {
1187
+ target = val.concepts[0].reference;
1188
+ }
1189
+ if (relType === NO_REFERENCE || target === NO_REFERENCE) return null;
1190
+ return { active: true, relType, target, group };
1191
+ };
1192
+
1193
+ // Effective relationships of a post-coordinated expression: the focus
1194
+ // concept(s) stated relationships (inherited) plus the expression's own
1195
+ // refinements. Stated ungrouped refinements go in group 0; each stated
1196
+ // refinement group gets a fresh group number beyond any inherited group so the
1197
+ // two never collide. This is sufficient for the common single-focus
1198
+ // expressions; it is not a full necessary normal form (no redundancy pruning
1199
+ // across inherited and stated attributes).
1200
+ _expressionRelRecords = function (expression) {
1201
+ const rels = [];
1202
+ for (const c of expression.concepts || []) {
1203
+ if (c.reference != null && c.reference !== NO_REFERENCE && c.reference >= 0) {
1204
+ for (const rel of this._conceptRelRecords(c.reference)) rels.push(rel);
1205
+ }
1206
+ }
1207
+ let maxGroup = 0;
1208
+ for (const r of rels) if (r.group > maxGroup) maxGroup = r.group;
1209
+ for (const ref of expression.refinements || []) {
1210
+ const rec = this._refinementToRel(ref, 0);
1211
+ if (rec) rels.push(rec);
1212
+ }
1213
+ let g = maxGroup;
1214
+ for (const grp of expression.refinementGroups || []) {
1215
+ g += 1;
1216
+ for (const ref of grp.refinements || []) {
1217
+ const rec = this._refinementToRel(ref, g);
1218
+ if (rec) rels.push(rec);
1219
+ }
1220
+ }
1221
+ return rels;
1222
+ };
1223
+
1224
+ // Does a post-coordinated expression satisfy an ECL constraint? Unlike the
1225
+ // enumerating evaluator (_evalECLNode), this tests a single expression by its
1226
+ // focus subsumption and its (normal-form) relationships, so validate-code can
1227
+ // accept a composed expression that is a *member* of an ECL value set even
1228
+ // though it never appears in the (precoordinated) expansion.
1229
+ _eclExpressionSatisfies = function (exprContext, node, opContext) {
1230
+ const expr = exprContext.expression;
1231
+ const rels = this._expressionRelRecords(expr);
1232
+ return this._eclExprNode(expr, rels, node, opContext);
1233
+ };
1234
+
1235
+ _eclExprNode = function (expr, rels, node, opContext) {
1236
+ if (!node) return false;
1237
+ if (opContext) opContext.deadCheck('ecl:exprSatisfies');
1238
+ switch (node.type) {
1239
+ case ECLNodeType.SUB_EXPRESSION_CONSTRAINT: {
1240
+ const focus = node.focus;
1241
+ if (focus.type === ECLNodeType.WILDCARD) return true;
1242
+ if (focus.type === ECLNodeType.MEMBER_OF) return false; // an expression is not a refset member
1243
+ if (focus.type === ECLNodeType.CONCEPT_REFERENCE) {
1244
+ return this._complexExprSatisfiesConcept(expr, focus.conceptId, node.operator);
1245
+ }
1246
+ return this._eclExprNode(expr, rels, focus, opContext); // parenthesised
1247
+ }
1248
+ case ECLNodeType.COMPOUND_EXPRESSION_CONSTRAINT: {
1249
+ const l = this._eclExprNode(expr, rels, node.left, opContext);
1250
+ const r = this._eclExprNode(expr, rels, node.right, opContext);
1251
+ switch (node.operator) {
1252
+ case ECLNodeType.CONJUNCTION: return l && r;
1253
+ case ECLNodeType.DISJUNCTION: return l || r;
1254
+ case ECLNodeType.EXCLUSION: return l && !r;
1255
+ default: throw new Error(`Unsupported ECL compound operator: ${node.operator}`);
1256
+ }
1257
+ }
1258
+ case ECLNodeType.REFINED_EXPRESSION_CONSTRAINT:
1259
+ return this._eclExprNode(expr, rels, node.base, opContext)
1260
+ && this._refinementMatches(rels, node.refinement, opContext);
1261
+ case ECLNodeType.CONCEPT_REFERENCE:
1262
+ return this._complexExprSatisfiesConcept(expr, node.conceptId, null);
1263
+ case ECLNodeType.WILDCARD:
1264
+ return true;
1265
+ case ECLNodeType.MEMBER_OF:
1266
+ return false;
1267
+ default:
1268
+ throw new Error(`Unsupported ECL node type for expression membership: ${node.type}`);
1269
+ }
1270
+ };
1271
+
1272
+ // Whether a post-coordinated expression's focus stands in the given hierarchy
1273
+ // relation to conceptId. A refined expression is always strictly more specific
1274
+ // than its focus, so it can never be an ancestor/self/parent/child edge — only
1275
+ // the descendant operators (<, <<) hold, via focus subsumption.
1276
+ _complexExprSatisfiesConcept = function (expr, conceptId, operator) {
1277
+ const cr = this.concepts.findConcept(conceptId);
1278
+ if (!cr.found) {
1279
+ throw new Error(`The SNOMED CT Concept ${conceptId} is not known`);
1280
+ }
1281
+ const target = cr.index;
1282
+ const focusRefs = (expr.concepts || [])
1283
+ .map(c => c.reference)
1284
+ .filter(r => r != null && r !== NO_REFERENCE && r >= 0);
1285
+ if (focusRefs.length === 0) return false;
1286
+ const subsumed = focusRefs.every(f => this.subsumes(target, f));
1287
+ switch (operator) {
1288
+ case ECLTokenType.DESCENDANT_OR_SELF_OF: // <<
1289
+ case ECLTokenType.DESCENDANT_OF: // <
1290
+ return subsumed;
1291
+ // exact / ancestors / parents / children: a refined expression has no such
1292
+ // materialised standing, so it is not a member.
1293
+ default:
1294
+ return false;
1295
+ }
1296
+ };
1297
+
994
1298
  // ── Set operation helpers ────────────────────────────────────────────────────
995
1299
 
996
1300
  /**
@@ -1065,16 +1369,10 @@ class SnomedServices {
1065
1369
  const rightSet = new Set(this._eclToIndexArray(right));
1066
1370
 
1067
1371
  if (left.eclWildcard) {
1068
- // Enumerate all active concepts minus the right set
1069
- const all = [];
1070
- for (let i = 0; i < this.concepts.count(); i++) {
1071
- if (opContext) opContext.deadCheck('ecl:minus');
1072
- const concept = this.concepts.getConceptByCount(i);
1073
- if (this.isActive(concept.index) && !rightSet.has(concept.index)) {
1074
- all.push(concept.index);
1075
- }
1076
- }
1077
- result.descendants = all;
1372
+ // Wildcard minus the right set: use the shared active-concept enumeration
1373
+ // (the root hierarchy) so `*` behaves consistently everywhere.
1374
+ result.descendants = this._eclEnumerateActiveConcepts(opContext)
1375
+ .filter(idx => !rightSet.has(idx));
1078
1376
  return result;
1079
1377
  }
1080
1378
 
@@ -1194,6 +1492,10 @@ class SnomedProvider extends BaseCSServices {
1194
1492
  return this.sct.totalCount;
1195
1493
  }
1196
1494
 
1495
+ async countAllCodes(excludeInactive) {
1496
+ return excludeInactive ? this.sct.activeConceptCount() : this.sct.totalCount;
1497
+ }
1498
+
1197
1499
  contentMode() {
1198
1500
  return CodeSystemContentMode.Complete;
1199
1501
  }
@@ -1279,7 +1581,7 @@ class SnomedProvider extends BaseCSServices {
1279
1581
  return this.sct.isActive(ctxt.getReference()) ? 'active' : 'inactive';
1280
1582
  }
1281
1583
 
1282
- async designations(context, displays) {
1584
+ async designations(context, displays, significantOnly = false) {
1283
1585
 
1284
1586
  const ctxt = await this.#ensureContext(context);
1285
1587
 
@@ -1300,17 +1602,53 @@ class SnomedProvider extends BaseCSServices {
1300
1602
 
1301
1603
  if (descriptionsRef !== 0) {
1302
1604
  const descriptionIndices = this.sct.refs.getReferences(descriptionsRef);
1303
-
1304
- for (const descIndex of descriptionIndices) {
1305
- const description = this.sct.descriptions.getDescription(descIndex);
1306
- const term = this.sct.strings.getEntry(description.iDesc).trim();
1307
- const langCode = this.getLanguageCode(description.lang);
1308
- const kind = this.sct.concepts.getConcept(description.kind);
1309
- const kid = String(kind.identity);
1310
- const kdesc = this.sct.getDisplayName(description.kind);
1311
- let use = { system: 'http://snomed.info/sct', code: kid, display : kdesc};
1312
-
1313
- displays.addDesignation(false, description.active ? 'active' : 'inactive', langCode, use, term);
1605
+ const K = this.sct._displayConstants();
1606
+
1607
+ if (significantOnly) {
1608
+ // $expand: emit the "significant" designations only — the FSN plus
1609
+ // the preferred synonym (US English), active descriptions only.
1610
+ // The preferred synonym is also the display, so it is added as the
1611
+ // display designation (which the expand worker selects for
1612
+ // `display` and skips from the emitted list) and additionally as a
1613
+ // Synonym designation so it still appears in the designation array.
1614
+ let fsn = null, prefSyn = null;
1615
+ for (const descIndex of descriptionIndices) {
1616
+ const description = this.sct.descriptions.getDescription(descIndex);
1617
+ if (!description.active) continue;
1618
+ const term = this.sct.strings.getEntry(description.iDesc).trim();
1619
+ const langCode = this.getLanguageCode(description.lang);
1620
+ const kind = this.sct.concepts.getConcept(description.kind);
1621
+ const use = { system: 'http://snomed.info/sct', code: String(kind.identity), display: this.sct.getDisplayName(description.kind) };
1622
+ if (description.kind === K.fsn) {
1623
+ if (!fsn) fsn = { langCode, use, term };
1624
+ } else if (this.sct._synonymIsPreferred(description)) {
1625
+ if (!prefSyn) prefSyn = { langCode, use, term };
1626
+ }
1627
+ }
1628
+ const display = this.sct.getDisplayName(ctxt.getReference());
1629
+ if (display) displays.addDesignation(true, 'active', 'en-US', null, display);
1630
+ if (fsn) displays.addDesignation(false, 'active', fsn.langCode, fsn.use, fsn.term);
1631
+ if (prefSyn) displays.addDesignation(false, 'active', prefSyn.langCode, prefSyn.use, prefSyn.term);
1632
+ } else {
1633
+ // $lookup: emit every description (preferred synonym first so the
1634
+ // display resolves correctly; order is not otherwise significant).
1635
+ const orderedIndices = descriptionIndices.slice().sort((a, b) => {
1636
+ const da = this.sct.descriptions.getDescription(a);
1637
+ const db = this.sct.descriptions.getDescription(b);
1638
+ const pa = this.sct._synonymIsPreferred(da) ? 0 : 1;
1639
+ const pb = this.sct._synonymIsPreferred(db) ? 0 : 1;
1640
+ return pa - pb;
1641
+ });
1642
+ for (const descIndex of orderedIndices) {
1643
+ const description = this.sct.descriptions.getDescription(descIndex);
1644
+ const term = this.sct.strings.getEntry(description.iDesc).trim();
1645
+ const langCode = this.getLanguageCode(description.lang);
1646
+ const kind = this.sct.concepts.getConcept(description.kind);
1647
+ const kid = String(kind.identity);
1648
+ const kdesc = this.sct.getDisplayName(description.kind);
1649
+ let use = { system: 'http://snomed.info/sct', code: kid, display : kdesc};
1650
+ displays.addDesignation(false, description.active ? 'active' : 'inactive', langCode, use, term);
1651
+ }
1314
1652
  }
1315
1653
  }
1316
1654
  } catch (error) {
@@ -1427,12 +1765,22 @@ class SnomedProvider extends BaseCSServices {
1427
1765
 
1428
1766
 
1429
1767
  if (!context) {
1430
- // Iterate all active root concepts
1768
+ // Iterate all active root concepts; the walk descends into each root's
1769
+ // children via includeCodeAndDescendants. `total` reports the real concept
1770
+ // count (not the root count) so the expansion size cap is meaningful, while
1771
+ // `keys` are the roots and nextContext bounds on keys.length.
1772
+ // activeRoots holds concept *ids* (BigInt); map them to concept indices so
1773
+ // the contexts and the getConceptChildren descent work (which key on index).
1774
+ const rootKeys = [];
1775
+ for (const rootId of this.sct.activeRoots) {
1776
+ const r = this.sct.concepts.findConcept(rootId);
1777
+ if (r.found) rootKeys.push(r.index);
1778
+ }
1431
1779
  return {
1432
1780
  context: null,
1433
- keys: this.sct.activeRoots.slice(),
1781
+ keys: rootKeys,
1434
1782
  current: 0,
1435
- total: this.sct.activeRoots.length
1783
+ total: this.sct.totalCount
1436
1784
  };
1437
1785
  } else {
1438
1786
  const ctxt = await this.#ensureContext(context);
@@ -1452,7 +1800,7 @@ class SnomedProvider extends BaseCSServices {
1452
1800
  }
1453
1801
 
1454
1802
  async nextContext(iteratorContext) {
1455
- if (iteratorContext.current >= iteratorContext.total) {
1803
+ if (iteratorContext.current >= iteratorContext.keys.length) {
1456
1804
  return null;
1457
1805
  }
1458
1806
 
@@ -1467,8 +1815,11 @@ class SnomedProvider extends BaseCSServices {
1467
1815
  if (ctxt) {
1468
1816
  if (!(ctxt instanceof SnomedExpressionContext) || ctxt.expression?.concepts.length == 1) {
1469
1817
  const time = this.sct.concepts.getConcept(ctxt.getReference()).effectiveTime;
1470
- const pascalEpoch = new Date(1899, 11, 30);
1471
- const date = new Date(pascalEpoch.getTime() + time * 86400000);
1818
+ // Pascal TDateTime epoch (1899-12-30) computed in UTC so the formatted
1819
+ // date is timezone-independent. new Date(1899,11,30) is LOCAL midnight,
1820
+ // which in zones ahead of UTC rolls the ISO date back by one day.
1821
+ const pascalEpochUTC = Date.UTC(1899, 11, 30);
1822
+ const date = new Date(pascalEpochUTC + time * 86400000);
1472
1823
  const dateStr = date.toISOString().slice(0, 10);
1473
1824
  this._addDateTimeProperty(params, 'property', 'effectiveTime', dateStr);
1474
1825
 
@@ -1582,7 +1933,56 @@ class SnomedProvider extends BaseCSServices {
1582
1933
  return new SnomedPrep(); // Simple filter context
1583
1934
  }
1584
1935
 
1585
- async filter(filterContext, forIteration, prop, op, value) {
1936
+ // Cache key pinning resolved analysis to the SNOMED edition url + version.
1937
+ // SNOMED always has both; the url varies across editions (incl. the test set).
1938
+ _snomedFilterCacheKey() {
1939
+ return this.system() + '|' + this.version();
1940
+ }
1941
+
1942
+ // Return a fresh filter context wrapping a cached ECL analysis, or null on
1943
+ // miss. The concept arrays are shared read-only (nothing mutates them for an
1944
+ // ECL set); only the cursor state is per-context.
1945
+ _getCachedEcl(fc) {
1946
+ if (!fc) {
1947
+ return null;
1948
+ }
1949
+ const byVersion = fc[SNOMED_FILTER_ANALYSIS];
1950
+ if (!byVersion) {
1951
+ return null;
1952
+ }
1953
+ const data = byVersion.get(this._snomedFilterCacheKey());
1954
+ if (!data) {
1955
+ return null;
1956
+ }
1957
+ const ctx = new SnomedFilterContext();
1958
+ ctx.descendants = data.descendants;
1959
+ ctx.members = data.members;
1960
+ ctx.matches = data.matches;
1961
+ ctx.eclAst = data.eclAst;
1962
+ return ctx;
1963
+ }
1964
+
1965
+ // Memoise an ECL analysis on fc, keyed by url+version. Skips iteration-
1966
+ // sensitive results (wildcards): their materialised form is not valid for the
1967
+ // other iteration mode, and their lazy form is cheap to recompute anyway.
1968
+ _cacheEcl(fc, resolved) {
1969
+ if (!fc || !resolved || resolved.eclWildcard || resolved.iterationSensitive) {
1970
+ return;
1971
+ }
1972
+ let byVersion = fc[SNOMED_FILTER_ANALYSIS];
1973
+ if (!byVersion) {
1974
+ byVersion = new Map();
1975
+ Object.defineProperty(fc, SNOMED_FILTER_ANALYSIS, { value: byVersion, enumerable: false, configurable: true, writable: true });
1976
+ }
1977
+ byVersion.set(this._snomedFilterCacheKey(), {
1978
+ descendants: resolved.descendants,
1979
+ members: resolved.members,
1980
+ matches: resolved.matches,
1981
+ eclAst: resolved.eclAst,
1982
+ });
1983
+ }
1984
+
1985
+ async filter(filterContext, forIteration, prop, op, value, fc) {
1586
1986
 
1587
1987
  if (prop === 'concept') {
1588
1988
  const id = this.sct.stringToIdOrZero(value);
@@ -1636,7 +2036,18 @@ class SnomedProvider extends BaseCSServices {
1636
2036
  }
1637
2037
 
1638
2038
  if (prop === 'constraint' && op === '=') {
1639
- filterContext.filters.push(await this.sct.filterECL(value, forIteration, this.opContext));
2039
+ // Reuse a previously resolved ECL analysis for this same cached value set
2040
+ // filter against the same code system (url+version). ECL resolution is the
2041
+ // expensive SNOMED filter to prepare; the resolved concept list is shared
2042
+ // read-only, wrapped in a fresh context so the iteration cursor is per-use.
2043
+ const cached = this._getCachedEcl(fc);
2044
+ if (cached) {
2045
+ filterContext.filters.push(cached);
2046
+ return null;
2047
+ }
2048
+ const resolved = await this.sct.filterECL(value, forIteration, this.opContext);
2049
+ this._cacheEcl(fc, resolved);
2050
+ filterContext.filters.push(resolved);
1640
2051
  return null;
1641
2052
  }
1642
2053
 
@@ -1700,6 +2111,11 @@ class SnomedProvider extends BaseCSServices {
1700
2111
  }
1701
2112
 
1702
2113
  async filterSize(filterContext, set) {
2114
+ // A wildcard (`*`) set is lazy - it carries the eclWildcard flag rather than a
2115
+ // materialised descendant list - so count the active concepts it denotes.
2116
+ if (set.eclWildcard && (!set.descendants || set.descendants.length === 0)) {
2117
+ return this.sct._eclEnumerateActiveConcepts(this.opContext).length;
2118
+ }
1703
2119
  if (set.matches && set.matches.length > 0) {
1704
2120
  return set.matches.length;
1705
2121
  } else if (set.members && set.members.length > 0) {
@@ -1746,11 +2162,19 @@ class SnomedProvider extends BaseCSServices {
1746
2162
  return conceptResult.message;
1747
2163
  }
1748
2164
 
2165
+ const ctxt = conceptResult.context;
2166
+ const reference = ctxt.getReference();
2167
+
1749
2168
  if (set.eclWildcard) {
1750
2169
  return this.sct.isActive(reference) ? ctxt : null;
1751
2170
  }
1752
- const ctxt = conceptResult.context;
1753
- const reference = ctxt.getReference();
2171
+
2172
+ // A post-coordinated expression validated against an ECL value set: test the
2173
+ // expression's membership against the constraint directly (subsumption +
2174
+ // refinements), rather than collapsing it to its focus concept.
2175
+ if (set.eclAst && ctxt.isComplex()) {
2176
+ return this.sct._eclExpressionSatisfies(ctxt, set.eclAst, this.opContext) ? ctxt : null;
2177
+ }
1754
2178
  let found = false;
1755
2179
 
1756
2180
  if (set.inactive !== undefined) {
@@ -1771,6 +2195,9 @@ class SnomedProvider extends BaseCSServices {
1771
2195
  }
1772
2196
  }
1773
2197
  } else if (set.matches && set.matches.length > 0) {
2198
+ // filterLocate is only ever called once per set (single code, validate
2199
+ // path), so a Set would cost O(n) to build for one lookup - a plain scan
2200
+ // is the right call here. filterCheck, which is looped, uses the Sets.
1774
2201
  found = set.matches.some(m => m.index === reference);
1775
2202
  } else if (set.members && set.members.length > 0) {
1776
2203
  found = set.members.some(m => m.ref === reference);
@@ -1815,11 +2242,11 @@ class SnomedProvider extends BaseCSServices {
1815
2242
  }
1816
2243
 
1817
2244
  if (set.matches && set.matches.length > 0) {
1818
- return set.matches.some(m => m.index === reference);
2245
+ return this.#matchesHas(set, reference);
1819
2246
  } else if (set.members && set.members.length > 0) {
1820
- return set.members.some(m => m.ref === reference);
2247
+ return this.#membersHas(set, reference);
1821
2248
  } else if (set.descendants && set.descendants.length > 0) {
1822
- return set.descendants.includes(reference);
2249
+ return this.#descendantsHas(set, reference);
1823
2250
  }
1824
2251
  if (set.eclWildcard) {
1825
2252
  return this.sct.isActive(reference);
@@ -1827,6 +2254,34 @@ class SnomedProvider extends BaseCSServices {
1827
2254
  return false;
1828
2255
  }
1829
2256
 
2257
+ // Lazily build and cache a membership Set on the filter context so repeated
2258
+ // filterLocate/filterCheck calls test membership in O(1) instead of scanning
2259
+ // the descendants/members/matches array each time. The length guard rebuilds
2260
+ // the Set if the underlying array was mutated (e.g. by #ensurePopulated).
2261
+ #descendantsHas(set, reference) {
2262
+ if (!set._descSet || set._descSetLen !== set.descendants.length) {
2263
+ set._descSet = new Set(set.descendants);
2264
+ set._descSetLen = set.descendants.length;
2265
+ }
2266
+ return set._descSet.has(reference);
2267
+ }
2268
+
2269
+ #membersHas(set, reference) {
2270
+ if (!set._memberSet || set._memberSetLen !== set.members.length) {
2271
+ set._memberSet = new Set(set.members.map(m => m.ref));
2272
+ set._memberSetLen = set.members.length;
2273
+ }
2274
+ return set._memberSet.has(reference);
2275
+ }
2276
+
2277
+ #matchesHas(set, reference) {
2278
+ if (!set._matchSet || set._matchSetLen !== set.matches.length) {
2279
+ set._matchSet = new Set(set.matches.map(m => m.index));
2280
+ set._matchSetLen = set.matches.length;
2281
+ }
2282
+ return set._matchSet.has(reference);
2283
+ }
2284
+
1830
2285
  #ensurePopulated(set) {
1831
2286
  if (set.populationDone) {
1832
2287
  return;