fhirsmith 0.13.2 → 0.13.4

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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,48 @@ All notable changes to the Health Intersections Node Server will be documented i
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [v0.13.4] - 2026-09-17
9
+
10
+ ### Changed
11
+
12
+ - Clean up TxCaps expansion parameter list
13
+
14
+ ### Fixed
15
+
16
+ - Fix performance issue with snomed text search
17
+ - clean up release process to avoid version mismatch, and to clean out old dockers
18
+
19
+ ### Tx Conformance Statement
20
+
21
+ FHIRsmith passed all 3507 HL7 terminology service tests (modes tx.fhir.org+omop+general+snomed+mimetypes+icd-11, tests v1.9.4, runner v6.10.4)
22
+
23
+ ## [v0.13.3] - 2026-09-10
24
+
25
+ ### Fixed
26
+
27
+ - Fix SNOMED CT display selection on national editions. `_displayRefsetOrder()` knew a display
28
+ language reference set for only six editions (International, US, UK and the test edition);
29
+ every other edition fell through to "the first description marked Preferred in any language
30
+ reference set", which on a multi-language edition means whichever description happened to be
31
+ stored first. The Belgian edition (11000172109) marks a Preferred synonym in Belgian French
32
+ (21000172104) and Belgian Dutch (31000172101) as well as in English, so a request that named
33
+ no `displayLanguage` got French for one concept, Dutch for the next and English for a third.
34
+ Editions not in the table now default to US then GB English, and the $lookup designation
35
+ order ranks the edition's display reference set ahead of "preferred anywhere", so the choice
36
+ no longer depends on import order. An edition with no English language reference set at all
37
+ still falls back to its own preferred synonym. This changes one tx-ecosystem expectation:
38
+ `bugs/sct-display-2` recorded "Counselling" for 409063005 on the International edition, which
39
+ is the GB English preferred term picked only because it is stored before the US one - the same
40
+ concept already expanded as "Counseling". The expectation is now "Counseling".
41
+ - Fix the language tag on the display designation in $expand. It was hard-coded to `en-US`
42
+ while its value came from the edition display, so on a multi-language edition a French or
43
+ Dutch term was published as English - which put it in an implementation guide's English
44
+ display column and made the real French designation read as a duplicate of it.
45
+
46
+ ### Tx Conformance Statement
47
+
48
+ FHIRsmith passed all 3507 HL7 terminology service tests (modes tx.fhir.org+omop+general+snomed+mimetypes+icd-11, tests v1.9.5-SNAPSHOT, runner v6.10.4
49
+
8
50
  ## [v0.13.2] - 2026-09-05
9
51
 
10
52
  ### Added
package/README.md CHANGED
@@ -259,6 +259,26 @@ Each GitHub Release includes:
259
259
  - `ghcr.io/healthintersections/fhirsmith:X.Y.Z`
260
260
  - **npm package** published to npmjs.org as `fhirsmith` *(if you add this)*
261
261
 
262
+ Release images carry OCI labels, so an image says which release it is without
263
+ having to trust the tag it was pulled under:
264
+
265
+ ```bash
266
+ docker inspect --format '{{json .Config.Labels}}' ghcr.io/healthintersections/fhirsmith:latest
267
+ ```
268
+
269
+ `org.opencontainers.image.version` is the release, `.revision` the commit it was
270
+ built from, and `.source` is what links the package to this repository on GHCR.
271
+
272
+ ### CI Images
273
+
274
+ Every push to main also publishes `:cibuild` and `:cibuild-<sha>` to the same
275
+ package. These are **not releases** - they are whatever was last merged. The
276
+ Docker Build workflow prunes all but the ten most recent afterwards, so they do
277
+ not bury the current release on the package page. To prune by hand (or to run
278
+ the one-off backfill of images published before that job existed), run the
279
+ **GHCR Cleanup** workflow from the Actions tab with `dry_run` on first, read the
280
+ log, then run it again with `dry_run` off.
281
+
262
282
  ### Creating a Release
263
283
 
264
284
  GitHub Actions will automatically:
@@ -283,7 +303,18 @@ GitHub Actions will automatically:
283
303
  ### Tx Conformance Statement
284
304
  {copy content from text-cases-summary.txt}
285
305
  ```
286
- 2. Update `package.json` & `package-lock.json` to have the same release version
306
+ 2. Update `package.json` & `package-lock.json` to the release version:
307
+
308
+ ```bash
309
+ npm version --no-git-tag-version X.Y.Z
310
+ npm install --package-lock-only
311
+ ```
312
+
313
+ This matters more than it looks. The server reports its version from
314
+ `package.json` (`server.js`, `tx/tx.js`) - not from the `APP_VERSION` build
315
+ arg - so a package.json left on the previous number ships a Docker image that
316
+ calls itself by the wrong release for good. The **Verify Version** job fails
317
+ the release if `package.json` or `package-lock.json` disagrees with the tag.
287
318
 
288
319
  3. Commit your changes:
289
320
  ```bash
@@ -94,7 +94,14 @@
94
94
  // (>100MB). Only applies to websites whose publish-setup.json has $.website.github = true.
95
95
  // Omit to leave them in place - the push will then fail. Relative paths resolve under the
96
96
  // publisher data folder
97
- "large-file-archive": "/absolute/path/to/large-file-archive"
97
+ "large-file-archive": "/absolute/path/to/large-file-archive",
98
+ // where to clone the IG registry from. Omit for the default,
99
+ // git@github.com:FHIR/ig-registry.git. Set this to an ssh_config Host alias when the
100
+ // server should push the registry with a repo-scoped deploy key instead of an account
101
+ // key - e.g. "git@github-ig-registry:FHIR/ig-registry.git", with a matching Host block
102
+ // in the service user's ~/.ssh/config carrying IdentityFile and IdentitiesOnly yes.
103
+ // The alias is what lets ssh tell this remote apart from the website remote.
104
+ "ig-registry-url": "git@github.com:FHIR/ig-registry.git"
98
105
  },
99
106
  "npmprojector": {
100
107
  "enabled": true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fhirsmith",
3
- "version": "0.13.2",
3
+ "version": "0.13.4",
4
4
  "txVersion": "1.9.5-SNAPSHOT",
5
5
  "description": "A Node.js server that provides a collection of tools to serve the FHIR ecosystem",
6
6
  "main": "server.js",
@@ -46,7 +46,7 @@
46
46
  "express-rate-limit": "^7.4.1",
47
47
  "express-session": "^1.19.0",
48
48
  "fast-xml-parser": "^5.3.4",
49
- "fhir-validator-wrapper": "1.3.0",
49
+ "fhir-validator-wrapper": "1.4.0",
50
50
  "fhirpath": "^4.8.3",
51
51
  "fs-extra": "^11.3.3",
52
52
  "ini": "^6.0.0",
@@ -1117,8 +1117,15 @@ class PublisherModule {
1117
1117
  // Step 1: Clone supporting repositories into the task directory
1118
1118
  const registryDir = path.join(taskDir, 'ig-registry');
1119
1119
 
1120
- await this.runCommand('git', ['clone', 'git@github.com:FHIR/ig-registry.git', registryDir],
1121
- {}, task.id, 'Cloning ig-registry');
1120
+ // The URL is configurable so that the credential arrangement can live in server config
1121
+ // rather than here. A deployment that pushes ig-registry with a repo-scoped deploy key
1122
+ // rather than an account key points this at an ssh_config Host alias carrying that key,
1123
+ // e.g. git@github-ig-registry:FHIR/ig-registry.git - the alias is the only way ssh can
1124
+ // tell this remote apart from the website remote, both being github.com.
1125
+ const igRegistryUrl = this.config['ig-registry-url'] || 'git@github.com:FHIR/ig-registry.git';
1126
+
1127
+ await this.runCommand('git', ['clone', igRegistryUrl, registryDir],
1128
+ {}, task.id, 'Cloning ig-registry from ' + igRegistryUrl);
1122
1129
 
1123
1130
  // Use website-configured history templates path if provided, otherwise clone the default repo
1124
1131
  let historyDir;
@@ -16,6 +16,8 @@ const {ConceptMap} = require("../library/conceptmap");
16
16
  const {ECLLexer, ECLParser, ECLNodeType, ECLTokenType} = require("../sct/ecl");
17
17
  const {Issue} = require("../library/operation-outcome");
18
18
  const {debugLog} = require("../operation-context");
19
+ const {SnomedTextIndex, rankMatches, filterTokens} = require("../sct/text-index");
20
+ const Logger = require("../../library/logger");
19
21
 
20
22
  // Symbol key under which a resolved ECL filter analysis is memoised directly on
21
23
  // the ValueSet compose.include.filter element (`fc`). Symbol-keyed and defined
@@ -28,6 +30,21 @@ const {debugLog} = require("../operation-context");
28
30
  // shared - it simply recomputes.
29
31
  const SNOMED_FILTER_ANALYSIS = Symbol('snomedFilterAnalysis');
30
32
 
33
+ // Description language index -> language code. The index is assigned at import
34
+ // time by SnomedImporter.mapLanguageCode(); this table is its inverse and must
35
+ // be kept in step with it.
36
+ const SCT_LANGUAGE_CODES = {
37
+ 1: 'en',
38
+ 2: 'fr',
39
+ 3: 'nl',
40
+ 4: 'es',
41
+ 5: 'sv',
42
+ 6: 'da',
43
+ 7: 'de',
44
+ 8: 'it',
45
+ 9: 'cs'
46
+ };
47
+
31
48
  // Context kinds matching Pascal enum
32
49
  const SnomedProviderContextKind = {
33
50
  CODE: 0,
@@ -112,6 +129,9 @@ class SnomedServices {
112
129
  this.strings = new SnomedStrings(sharedData.strings);
113
130
  this.words = new SnomedWords(sharedData.words);
114
131
  this.stems = new SnomedStems(sharedData.stems);
132
+ // Free-text search index, built on first search (see getTextIndex)
133
+ this._textIndex = null;
134
+ this._textIndexPromise = null;
115
135
  this.refs = new SnomedReferences(sharedData.refs);
116
136
  this.descriptions = new SnomedDescriptions(sharedData.desc);
117
137
  this.descriptionIndex = new SnomedDescriptionIndex(sharedData.descRef);
@@ -346,8 +366,20 @@ class SnomedServices {
346
366
  // preferred synonym in more than one dialect at once (the International
347
367
  // edition ships both US English 509007 and GB English 508004, each marking a
348
368
  // different synonym Preferred). The edition default decides which term is
349
- // shown. Editions not listed here fall back to "preferred synonym in any
350
- // language refset" (the historical behaviour), so no edition regresses.
369
+ // shown.
370
+ //
371
+ // Editions not listed here default to English (US then GB) rather than to
372
+ // "preferred synonym in any language refset". A national edition carries a
373
+ // Preferred synonym in each of its own language refsets as well as in English
374
+ // (e.g. the Belgian edition 11000172109 marks a French term Preferred in
375
+ // 21000172104 and a Dutch one in 31000172101), so "any refset" resolved to
376
+ // whichever description happened to be stored first - French for one concept,
377
+ // Dutch for the next, English for a third. A caller who asked for no
378
+ // particular language got an arbitrary mix. English is the only defensible
379
+ // default: it is the one language every edition has, and it is what a request
380
+ // with no displayLanguage is taken to mean everywhere else in the server. An
381
+ // edition with no English at all still falls through to the any-refset step
382
+ // below, so nothing regresses.
351
383
  _displayRefsetOrder() {
352
384
  if (this._dispOrder) return this._dispOrder;
353
385
  const idx = (id) => { const r = this.concepts.findConcept(id); return r.found ? r.index : -1; };
@@ -361,7 +393,7 @@ class SnomedServices {
361
393
  '83821000000107': [GB], // UK Edition
362
394
  '999000021000000109': [GB] // UK Clinical Edition
363
395
  };
364
- const ids = byEdition[String(this.edition)] || [];
396
+ const ids = byEdition[String(this.edition)] || [US, GB];
365
397
  this._dispOrder = ids.map(idx).filter((i) => i >= 0);
366
398
  return this._dispOrder;
367
399
  }
@@ -400,59 +432,80 @@ class SnomedServices {
400
432
  return false;
401
433
  }
402
434
 
403
- // Return a concept's display: the synonym marked Preferred in the US English
404
- // language reference set; failing that the FSN; failing that the first active
405
- // description. Previously this returned the first active description outright,
406
- // which is only the preferred term by accident of import order.
407
- getDisplayName(reference = 0) {
435
+ // Return a concept's display, together with the language it is actually in:
436
+ // the synonym marked Preferred in the edition's display language reference
437
+ // set; failing that a preferred synonym in any language refset; failing that
438
+ // the FSN; failing that the first active description. Previously this
439
+ // returned the first active description outright, which is only the preferred
440
+ // term by accident of import order.
441
+ //
442
+ // The language matters as much as the term: the display is emitted as a
443
+ // designation (DesignationUse.DISPLAY) during $expand, and tagging a French
444
+ // term as en-US puts it in an IG's English display column and makes the real
445
+ // French designation look like a duplicate.
446
+ //
447
+ // Returns {term, lang} - lang is a language code, or null when no description
448
+ // could be read.
449
+ getDisplayNameEx(reference = 0) {
450
+ const none = { term: '', lang: null };
408
451
  try {
409
452
  const concept = this.concepts.getConcept(reference);
410
453
  const descriptionsRef = concept.descriptions;
411
454
 
412
455
  if (descriptionsRef === 0) {
413
- return '';
456
+ return none;
414
457
  }
415
458
 
416
459
  const descriptionIndices = this.refs.getReferences(descriptionsRef);
417
460
  const K = this._displayConstants();
461
+ const pick = (description) => ({
462
+ term: this.strings.getEntry(description.iDesc).trim(),
463
+ lang: SCT_LANGUAGE_CODES[description.lang] || 'en'
464
+ });
418
465
 
419
466
  // 1. Preferred synonym in the edition's default display refset(s), tried
420
467
  // in priority order. This is what disambiguates dialects: on the
421
468
  // International edition a concept may be Preferred in both US and GB
422
- // English, and the edition default (US English) must win.
469
+ // English, and the edition default (US English) must win. It is also
470
+ // what keeps a multi-language national edition from picking a
471
+ // different language for each concept.
423
472
  for (const refsetIdx of this._displayRefsetOrder()) {
424
473
  for (const descIndex of descriptionIndices) {
425
474
  const description = this.descriptions.getDescription(descIndex);
426
475
  if (!description.active || description.kind !== K.synonym) continue;
427
476
  if (this._descAcceptability(description, refsetIdx) === K.preferred) {
428
- return this.strings.getEntry(description.iDesc).trim();
477
+ return pick(description);
429
478
  }
430
479
  }
431
480
  }
432
481
 
433
482
  // 2. Fallback: preferred synonym in ANY language refset; then FSN; then
434
- // the first active description. Used for editions without a mapped
435
- // default refset, and for concepts with no preferred synonym there.
436
- let fsnTerm = '';
437
- let firstActive = '';
483
+ // the first active description. Reached only by editions with no
484
+ // English language refset at all, and by concepts with no preferred
485
+ // synonym in the edition's display refsets.
486
+ let fsn = null;
487
+ let firstActive = null;
438
488
  for (const descIndex of descriptionIndices) {
439
489
  const description = this.descriptions.getDescription(descIndex);
440
490
  if (!description.active) continue;
441
- const term = this.strings.getEntry(description.iDesc).trim();
442
- if (firstActive === '') firstActive = term;
491
+ if (firstActive === null) firstActive = pick(description);
443
492
  if (this._synonymIsPreferred(description)) {
444
- return term; // preferred synonym (any English language refset)
493
+ return pick(description); // preferred synonym (any language refset)
445
494
  }
446
- if (description.kind === K.fsn && fsnTerm === '') {
447
- fsnTerm = term;
495
+ if (description.kind === K.fsn && fsn === null) {
496
+ fsn = pick(description);
448
497
  }
449
498
  }
450
- return fsnTerm || firstActive || '';
499
+ return fsn || firstActive || none;
451
500
  } catch (error) {
452
- return '';
501
+ return none;
453
502
  }
454
503
  }
455
504
 
505
+ getDisplayName(reference = 0) {
506
+ return this.getDisplayNameEx(reference).term;
507
+ }
508
+
456
509
  getConceptDescendants(reference) {
457
510
  try {
458
511
  const allDescRef = this.concepts.getAllDesc(reference);
@@ -1434,16 +1487,104 @@ class SnomedServices {
1434
1487
  };
1435
1488
 
1436
1489
 
1437
- searchFilter(searchText, includeInactive = false, exactMatch = false) {
1438
- const result = new SnomedFilterContext();
1490
+ /**
1491
+ * Free-text search over active descriptions.
1492
+ *
1493
+ * Uses the in-memory word index (see tx/sct/text-index.js), built the first
1494
+ * time this edition is searched: every filter token (stop words aside) must
1495
+ * match a word of the concept's active descriptions, either as a prefix or
1496
+ * by sharing its stem. Filters with no indexable token (only 1-character
1497
+ * tokens), or an edition whose index failed to build, fall back to
1498
+ * scanSearch().
1499
+ *
1500
+ * Previously the third parameter was `exactMatch`, which the provider fed
1501
+ * from its unrelated `sort` flag - so $expand with a filter on an
1502
+ * include-all-codes value set matched ANY term by brute-force scan: 10k+
1503
+ * matches for a typical multi-word filter, with the event loop blocked for
1504
+ * the whole scan (tx.fhir.org incident 2026-09-17).
1505
+ *
1506
+ * @param {SearchFilterText} searchText
1507
+ * @param {boolean} includeInactive - include inactive concepts
1508
+ * @param {OperationContext|null} opContext - for yielding / deadline checks
1509
+ * @returns {Promise<SnomedFilterContext>}
1510
+ */
1511
+ async searchFilter(searchText, includeInactive = false, opContext = null) {
1512
+ const filterText = (searchText && searchText.filter ? searchText.filter : '').toLowerCase().trim();
1513
+ const tokens = filterTokens(filterText);
1514
+ if (tokens.length > 0) {
1515
+ const index = await this.getTextIndex(opContext);
1516
+ if (index) {
1517
+ const result = new SnomedFilterContext();
1518
+ const ordinals = index.search(tokens, opContext);
1519
+ if (opContext) {
1520
+ await opContext.checkAndYield('sct:searchFilter');
1521
+ }
1522
+ result.matches = rankMatches(this, index, ordinals, filterText, tokens, includeInactive);
1523
+ return result;
1524
+ }
1525
+ }
1526
+ return await this.scanSearch(filterText, includeInactive, opContext);
1527
+ }
1528
+
1529
+ /**
1530
+ * The edition's text index, building it on first use. The build is shared:
1531
+ * concurrent searches wait on the same promise, and time spent waiting is
1532
+ * not charged to the waiting operation's compute budget. Resolves to null
1533
+ * if the build failed (the next search retries it).
1534
+ * @param {OperationContext|null} opContext
1535
+ * @returns {Promise<SnomedTextIndex|null>}
1536
+ */
1537
+ async getTextIndex(opContext = null) {
1538
+ if (this._textIndex) {
1539
+ return this._textIndex;
1540
+ }
1541
+ if (!this._textIndexPromise) {
1542
+ const started = Date.now();
1543
+ // Logger fetched lazily: importers and tests require this module too
1544
+ const textIndexLog = Logger.getInstance().child({module: 'tx-sct'});
1545
+ this._textIndexPromise = SnomedTextIndex.build(this).then(index => {
1546
+ this._textIndex = index;
1547
+ textIndexLog.info(`SNOMED text index for ${this.versionUri}: ${index.words.length} words, built in ${Date.now() - started}ms`);
1548
+ return index;
1549
+ }, error => {
1550
+ this._textIndexPromise = null;
1551
+ textIndexLog.error(`SNOMED text index build failed for ${this.versionUri}: ${error.message}`);
1552
+ return null;
1553
+ });
1554
+ }
1555
+ if (opContext && typeof opContext.waitFor === 'function') {
1556
+ return await opContext.waitFor(this._textIndexPromise, 'sct:textIndex');
1557
+ }
1558
+ return await this._textIndexPromise;
1559
+ }
1439
1560
 
1440
- // Simplified search - in full implementation would use stemming and word indexes
1441
- const searchTerms = searchText.filter.toLowerCase().split(/\s+/);
1561
+ /**
1562
+ * Brute-force scan of every concept's active descriptions: every
1563
+ * whitespace-separated term of the filter must appear (as a substring) in
1564
+ * the same description. Expensive (hundreds of ms on a full edition), so it
1565
+ * yields through opContext.checkAndYield and the compute deadline applies.
1566
+ *
1567
+ * @param {string} filterText - lower-cased, trimmed
1568
+ * @param {boolean} includeInactive
1569
+ * @param {OperationContext|null} opContext
1570
+ * @returns {Promise<SnomedFilterContext>}
1571
+ */
1572
+ async scanSearch(filterText, includeInactive = false, opContext = null) {
1573
+ const result = new SnomedFilterContext();
1574
+ const searchTerms = filterText.split(/\s+/).filter(t => t.length > 0);
1442
1575
  const matches = [];
1576
+ if (searchTerms.length === 0) {
1577
+ result.matches = matches;
1578
+ return result;
1579
+ }
1443
1580
 
1444
- // Search through all concepts
1445
- for (let i = 0; i < this.concepts.count(); i++) {
1446
- const conceptIndex = i * this.concepts.constructor.CONCEPT_SIZE;
1581
+ const conceptCount = this.concepts.count();
1582
+ const conceptSize = this.concepts.constructor.CONCEPT_SIZE;
1583
+ for (let i = 0; i < conceptCount; i++) {
1584
+ if (opContext && (i & 0x3FF) === 0) {
1585
+ await opContext.checkAndYield('sct:searchFilter');
1586
+ }
1587
+ const conceptIndex = i * conceptSize;
1447
1588
 
1448
1589
  try {
1449
1590
  const concept = this.concepts.getConcept(conceptIndex);
@@ -1462,25 +1603,23 @@ class SnomedServices {
1462
1603
  const description = this.descriptions.getDescription(descIndex);
1463
1604
  if (description.active) {
1464
1605
  const term = this.strings.getEntry(description.iDesc).toLowerCase();
1465
-
1466
- if (exactMatch) {
1467
- // All search terms must be present
1468
- matchFound = searchTerms.every(searchTerm => term.includes(searchTerm));
1469
- } else {
1470
- // Any search term can match
1471
- matchFound = searchTerms.some(searchTerm => term.includes(searchTerm));
1472
- }
1473
-
1474
- if (matchFound) {
1606
+ if (searchTerms.every(searchTerm => term.includes(searchTerm))) {
1607
+ matchFound = true;
1475
1608
  // Calculate priority based on match quality
1476
- if (term === searchText.filter.toLowerCase()) {
1477
- priority = 100; // Exact match
1478
- } else if (term.startsWith(searchText.filter.toLowerCase())) {
1479
- priority = 50; // Prefix match
1609
+ let p;
1610
+ if (term === filterText) {
1611
+ p = 100; // Exact match
1612
+ } else if (term.startsWith(filterText)) {
1613
+ p = 50; // Prefix match
1480
1614
  } else {
1481
- priority = 10; // Contains match
1615
+ p = 10; // Contains match
1616
+ }
1617
+ if (p > priority) {
1618
+ priority = p;
1619
+ }
1620
+ if (priority === 100) {
1621
+ break;
1482
1622
  }
1483
- break;
1484
1623
  }
1485
1624
  }
1486
1625
  }
@@ -1493,6 +1632,9 @@ class SnomedServices {
1493
1632
  });
1494
1633
  }
1495
1634
  } catch (error) {
1635
+ if (error instanceof Issue || (error && error.abandoned)) {
1636
+ throw error;
1637
+ }
1496
1638
  // Skip problematic concepts
1497
1639
  continue;
1498
1640
  }
@@ -1687,8 +1829,16 @@ class SnomedProvider extends BaseCSServices {
1687
1829
  if (!prefSyns.has(langCode)) prefSyns.set(langCode, { langCode, use, term });
1688
1830
  }
1689
1831
  }
1690
- const display = this.sct.getDisplayName(ctxt.getReference());
1691
- if (display) displays.addDesignation(true, 'active', 'en-US', null, display);
1832
+ // Tagged with the language the term is actually in, not a
1833
+ // hard-coded en-US: on a multi-language edition the edition
1834
+ // display can be French or Dutch, and mislabelling it as English
1835
+ // is what puts it in an IG's English display column and makes the
1836
+ // real French designation look like a duplicate of it. English
1837
+ // stays 'en-US' (the term comes from the US English language
1838
+ // reference set), so nothing changes for English editions.
1839
+ const display = this.sct.getDisplayNameEx(ctxt.getReference());
1840
+ const displayLang = (!display.lang || display.lang === 'en') ? 'en-US' : display.lang;
1841
+ if (display.term) displays.addDesignation(true, 'active', displayLang, null, display.term);
1692
1842
  // FSNs before synonyms, matching the designation order the published
1693
1843
  // tx-ecosystem expectations were written against (sct/expand-inactive).
1694
1844
  // Which of the two is chosen as the display is settled by
@@ -1697,14 +1847,31 @@ class SnomedProvider extends BaseCSServices {
1697
1847
  for (const d of fsns.values()) displays.addDesignation(false, 'active', d.langCode, d.use, d.term);
1698
1848
  for (const d of prefSyns.values()) displays.addDesignation(false, 'active', d.langCode, d.use, d.term);
1699
1849
  } else {
1700
- // $lookup: emit every description (preferred synonym first so the
1701
- // display resolves correctly; order is not otherwise significant).
1850
+ // $lookup: emit every description. Order matters only for the
1851
+ // display: a request with no displayLanguage takes the first
1852
+ // designation Designations._isPreferred() accepts, which is any
1853
+ // SNOMED synonym. Ranking by the edition's display language
1854
+ // reference set first (rather than by "preferred in any refset")
1855
+ // keeps that pick in one language - otherwise a national edition
1856
+ // answers $lookup in French for one concept and Dutch for the
1857
+ // next, depending on description storage order.
1858
+ const K2 = this.sct._displayConstants();
1859
+ const displayRefsets = this.sct._displayRefsetOrder();
1860
+ // Buckets: 0 = preferred in the edition's display refset, 1 =
1861
+ // preferred in some other language refset, 2 = everything else
1862
+ // (FSNs and acceptable synonyms, left in their original order -
1863
+ // Array.sort is stable, so nothing else about the output moves).
1864
+ const rank = (description) => {
1865
+ if (description.kind !== K2.synonym) return 2;
1866
+ for (const refsetIdx of displayRefsets) {
1867
+ if (this.sct._descAcceptability(description, refsetIdx) === K2.preferred) return 0;
1868
+ }
1869
+ return this.sct._synonymIsPreferred(description) ? 1 : 2;
1870
+ };
1702
1871
  const orderedIndices = descriptionIndices.slice().sort((a, b) => {
1703
1872
  const da = this.sct.descriptions.getDescription(a);
1704
1873
  const db = this.sct.descriptions.getDescription(b);
1705
- const pa = this.sct._synonymIsPreferred(da) ? 0 : 1;
1706
- const pb = this.sct._synonymIsPreferred(db) ? 0 : 1;
1707
- return pa - pb;
1874
+ return rank(da) - rank(db);
1708
1875
  });
1709
1876
  for (const descIndex of orderedIndices) {
1710
1877
  const description = this.sct.descriptions.getDescription(descIndex);
@@ -1733,18 +1900,7 @@ class SnomedProvider extends BaseCSServices {
1733
1900
  }
1734
1901
 
1735
1902
  getLanguageCode(langIndex) {
1736
- const languageMap = {
1737
- 1: 'en',
1738
- 2: 'fr',
1739
- 3: 'nl',
1740
- 4: 'es',
1741
- 5: 'sv',
1742
- 6: 'da',
1743
- 7: 'de',
1744
- 8: 'it',
1745
- 9: 'cs'
1746
- };
1747
- return languageMap[langIndex] || 'en';
1903
+ return SCT_LANGUAGE_CODES[langIndex] || 'en';
1748
1904
  }
1749
1905
 
1750
1906
  // Lookup methods
@@ -2386,8 +2542,10 @@ class SnomedProvider extends BaseCSServices {
2386
2542
  }
2387
2543
 
2388
2544
  // Search filter
2545
+ // eslint-disable-next-line no-unused-vars
2389
2546
  async searchFilter(filterContext, filter, sort) {
2390
- let f = this.sct.searchFilter(filter, false, sort);
2547
+ // `sort` is not a match-mode flag: results are always ranked.
2548
+ let f = await this.sct.searchFilter(filter, false, this.opContext);
2391
2549
  filterContext.filters.push(f);
2392
2550
  return f;
2393
2551
  }
@@ -24,8 +24,8 @@ const Extensions = {
24
24
  if (resource.jsonObj) {
25
25
  resource = resource.jsonObj
26
26
  }
27
- if (resource.implicitRules) {
28
- throw new Issue("error", "business-rule", null, null, 'Cannot process resource "'+name+'" due to the presence of implicit rules @'+place);
27
+ if (resource.implicitRules && 'https://hl7.org/fhir/implicit-rules/omitted-mandatory-interversion-code' != resource.implicitRules) {
28
+ throw new Issue("error", "business-rule", null, null, 'Cannot process resource "'+name+'" due to the presence of implicit rules \''+resource.implicitRules+'\' @'+place);
29
29
  }
30
30
  },
31
31
 
@@ -972,6 +972,39 @@ class OperationContext {
972
972
  }
973
973
  }
974
974
 
975
+ /**
976
+ * Await something this operation does not itself compute - typically a
977
+ * shared structure another request is building - without charging the wait
978
+ * to the compute deadline. The current compute slice is closed before
979
+ * waiting and a new one opened after; aborts if the client disconnected
980
+ * meanwhile.
981
+ *
982
+ * @param {Promise} promise
983
+ * @param {string} place - Location identifier for debugging
984
+ * @returns {Promise<*>} what the promise resolves to
985
+ */
986
+ async waitFor(promise, place = 'unknown') {
987
+ const now = performance.now();
988
+ this._clock.compute += now - this._clock.sliceStart;
989
+ let result;
990
+ try {
991
+ result = await promise;
992
+ } finally {
993
+ const resumed = performance.now();
994
+ this._clock.sliceStart = resumed;
995
+ this._clock.lastYield = resumed;
996
+ }
997
+ if (this._clock.clientGone) {
998
+ 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`);
1001
+ error.abandoned = true;
1002
+ error.diagnostics = this.diagnostics();
1003
+ throw error;
1004
+ }
1005
+ return result;
1006
+ }
1007
+
975
1008
  unSeeAll() {
976
1009
  this.contexts = [];
977
1010
  }
@@ -0,0 +1,419 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * In-memory word index for free-text SNOMED CT search ($expand ... filter=).
5
+ *
6
+ * The cache files produced by the Pascal importer carry no words/stems
7
+ * index, and the ones produced by the JS importer carry one keyed on
8
+ * language-specific stems of every description (inactive ones included). So
9
+ * rather than depend on what a given cache happens to hold, the index is
10
+ * built here, in memory, the first time an edition is text-searched:
11
+ *
12
+ * - every alphanumeric token (Unicode letters and digits, lower-cased,
13
+ * 2+ characters) of every ACTIVE description is a "word"
14
+ * - words[] is sorted, so a filter token matches every word it is a prefix
15
+ * of (a user who has typed "radioth" finds "radiotherapy")
16
+ * - each word is also filed under its Porter stem, so a filter token matches
17
+ * the other inflections of the same stem ("fractures" finds "fracture")
18
+ * - postings[w] is the ascending list of concept ORDINALS (reference /
19
+ * CONCEPT_SIZE) whose active descriptions contain word w
20
+ * - wordCounts[ordinal] is the number of distinct words the concept has,
21
+ * used to rank concepts whose terms are mostly the filter above ones where
22
+ * the filter is a small part of a long term
23
+ *
24
+ * A search requires every (non stop-word) filter token to match somewhere in
25
+ * the concept's active descriptions. For the full International Edition the
26
+ * build takes a few seconds and the index a few tens of MB; it yields to the
27
+ * event loop as it goes, and a search takes milliseconds once it exists.
28
+ */
29
+
30
+ const natural = require('natural');
31
+
32
+ const stemmer = natural.PorterStemmer;
33
+
34
+ // Only the short function words that make a multi-word filter fail to match
35
+ // anything when required. Not "with", "without", "no", "not" - those change
36
+ // the meaning of a clinical term.
37
+ const STOP_WORDS = new Set(['a', 'an', 'and', 'as', 'at', 'by', 'for', 'from', 'in', 'into', 'of', 'on', 'or', 'the', 'to']);
38
+
39
+ const TOKEN_RE = /[\p{L}\p{N}]+/gu;
40
+
41
+ const MIN_WORD_LENGTH = 2;
42
+
43
+ // How long the build runs before giving the event loop a turn (ms).
44
+ const BUILD_SLICE = 20;
45
+
46
+ // How many of the stem-ranked candidates get the (more expensive)
47
+ // description-level ranking.
48
+ const RANK_REFINE_LIMIT = 200;
49
+
50
+ /**
51
+ * Split text into lower-cased word tokens.
52
+ * @param {string} text
53
+ * @returns {string[]}
54
+ */
55
+ function tokenize(text) {
56
+ if (!text) {
57
+ return [];
58
+ }
59
+ return text.toLowerCase().match(TOKEN_RE) || [];
60
+ }
61
+
62
+ /**
63
+ * The tokens of a filter that the index can search on: 2+ characters, and
64
+ * stop words dropped unless nothing else is left.
65
+ * @param {string} filterText
66
+ * @returns {string[]} distinct tokens, in filter order
67
+ */
68
+ function filterTokens(filterText) {
69
+ const tokens = [...new Set(tokenize(filterText))].filter(t => t.length >= MIN_WORD_LENGTH);
70
+ const content = tokens.filter(t => !STOP_WORDS.has(t));
71
+ return content.length > 0 ? content : tokens;
72
+ }
73
+
74
+ function yieldToEventLoop() {
75
+ return new Promise(resolve => setImmediate(resolve));
76
+ }
77
+
78
+ class SnomedTextIndex {
79
+ constructor(words, postings, stemWords, wordCounts, conceptSize) {
80
+ this.words = words; // sorted string[]
81
+ this.postings = postings; // Uint32Array[] parallel to words
82
+ this.stemWords = stemWords; // Map stem -> word ids (number[])
83
+ this.wordCounts = wordCounts; // Uint16Array by concept ordinal
84
+ this.conceptSize = conceptSize;
85
+ this.scratch = new Uint8Array(wordCounts.length);
86
+ }
87
+
88
+ /**
89
+ * Build the index for a loaded edition. Yields to the event loop every
90
+ * BUILD_SLICE ms of work.
91
+ * @param {object} sct - SnomedServices
92
+ * @returns {Promise<SnomedTextIndex>}
93
+ */
94
+ static async build(sct) {
95
+ const concepts = sct.concepts;
96
+ const conceptSize = concepts.constructor.CONCEPT_SIZE;
97
+ const conceptCount = concepts.count();
98
+ const descMaster = sct.descriptions.master;
99
+
100
+ const wordIds = new Map(); // word -> provisional id
101
+ const lists = []; // provisional id -> number[] of ordinals
102
+ const lastSeen = []; // provisional id -> last ordinal added
103
+ const wordCounts = new Uint16Array(conceptCount);
104
+
105
+ let sliceStart = performance.now();
106
+ for (let ordinal = 0; ordinal < conceptCount; ordinal++) {
107
+ if ((ordinal & 0xFF) === 0 && performance.now() - sliceStart >= BUILD_SLICE) {
108
+ await yieldToEventLoop();
109
+ sliceStart = performance.now();
110
+ }
111
+ const descriptionsRef = concepts.getDescriptions(ordinal * conceptSize);
112
+ if (descriptionsRef === 0) {
113
+ continue;
114
+ }
115
+ let distinct = 0;
116
+ for (const descIndex of sct.refs.getReferences(descriptionsRef)) {
117
+ if (descMaster.readUInt8(descIndex + 4) === 0) {
118
+ continue; // inactive description
119
+ }
120
+ const term = sct.strings.getEntry(descMaster.readUInt32LE(descIndex));
121
+ for (const word of tokenize(term)) {
122
+ if (word.length < MIN_WORD_LENGTH) {
123
+ continue;
124
+ }
125
+ let id = wordIds.get(word);
126
+ if (id === undefined) {
127
+ id = lists.length;
128
+ wordIds.set(word, id);
129
+ lists.push([]);
130
+ lastSeen.push(-1);
131
+ }
132
+ if (lastSeen[id] !== ordinal) {
133
+ lastSeen[id] = ordinal;
134
+ lists[id].push(ordinal);
135
+ distinct++;
136
+ }
137
+ }
138
+ }
139
+ wordCounts[ordinal] = Math.min(distinct, 0xFFFF);
140
+ }
141
+
142
+ // Final layout: words sorted, postings as typed arrays, stems -> words.
143
+ const words = [...wordIds.keys()].sort();
144
+ const postings = new Array(words.length);
145
+ const stemWords = new Map();
146
+ sliceStart = performance.now();
147
+ for (let i = 0; i < words.length; i++) {
148
+ if ((i & 0xFF) === 0 && performance.now() - sliceStart >= BUILD_SLICE) {
149
+ await yieldToEventLoop();
150
+ sliceStart = performance.now();
151
+ }
152
+ const word = words[i];
153
+ const id = wordIds.get(word);
154
+ postings[i] = Uint32Array.from(lists[id]);
155
+ lists[id] = null;
156
+ const stem = stemmer.stem(word);
157
+ let ids = stemWords.get(stem);
158
+ if (!ids) {
159
+ ids = [];
160
+ stemWords.set(stem, ids);
161
+ }
162
+ ids.push(i);
163
+ }
164
+ return new SnomedTextIndex(words, postings, stemWords, wordCounts, conceptSize);
165
+ }
166
+
167
+ /**
168
+ * The ids of the words a filter token matches.
169
+ *
170
+ * A token always matches the words that share its stem ("fractures" finds
171
+ * "fracture"). Only the LAST token of the filter also matches the words it
172
+ * is a prefix of - that one may be a word the user is still typing
173
+ * ("radioth" -> "radiotherapy"), whereas treating a completed word as a
174
+ * prefix matches unrelated terms ("anal" -> "analog").
175
+ *
176
+ * @param {string} token
177
+ * @param {boolean} allowPrefix
178
+ * @returns {number[]}
179
+ */
180
+ wordsFor(token, allowPrefix = false) {
181
+ const ids = new Set();
182
+ let lo = 0;
183
+ let hi = this.words.length;
184
+ while (lo < hi) {
185
+ const mid = (lo + hi) >>> 1;
186
+ if (this.words[mid] < token) {
187
+ lo = mid + 1;
188
+ } else {
189
+ hi = mid;
190
+ }
191
+ }
192
+ if (allowPrefix) {
193
+ for (let i = lo; i < this.words.length && this.words[i].startsWith(token); i++) {
194
+ ids.add(i);
195
+ }
196
+ } else if (lo < this.words.length && this.words[lo] === token) {
197
+ ids.add(lo);
198
+ }
199
+ const stemmed = this.stemWords.get(stemmer.stem(token));
200
+ if (stemmed) {
201
+ for (const i of stemmed) {
202
+ ids.add(i);
203
+ }
204
+ }
205
+ return [...ids];
206
+ }
207
+
208
+ /**
209
+ * Concept ordinals whose active descriptions match every token.
210
+ * @param {string[]} tokens - from filterTokens()
211
+ * @param {object} [opContext] - for deadCheck
212
+ * @returns {number[]} ascending ordinals
213
+ */
214
+ search(tokens, opContext = null) {
215
+ // Deliberately synchronous: the scratch bitmap below is shared by every
216
+ // search on this edition, so nothing may interleave with it.
217
+ if (tokens.length === 0) {
218
+ return [];
219
+ }
220
+ const perToken = tokens.map((token, i) => {
221
+ const ids = this.wordsFor(token, i === tokens.length - 1);
222
+ let size = 0;
223
+ for (const id of ids) {
224
+ size += this.postings[id].length;
225
+ }
226
+ return { ids, size };
227
+ });
228
+ perToken.sort((a, b) => a.size - b.size);
229
+ if (perToken[0].size === 0) {
230
+ return [];
231
+ }
232
+
233
+ const mark = this.scratch;
234
+ // Candidates: the union for the rarest token.
235
+ let candidates;
236
+ const first = perToken[0].ids;
237
+ if (first.length === 1) {
238
+ candidates = Array.from(this.postings[first[0]]);
239
+ } else {
240
+ mark.fill(0);
241
+ for (const id of first) {
242
+ for (const ordinal of this.postings[id]) {
243
+ mark[ordinal] = 1;
244
+ }
245
+ }
246
+ candidates = [];
247
+ for (let o = 0; o < mark.length; o++) {
248
+ if (mark[o]) {
249
+ candidates.push(o);
250
+ }
251
+ }
252
+ }
253
+
254
+ // Narrow by each further token.
255
+ for (let t = 1; t < perToken.length && candidates.length > 0; t++) {
256
+ if (opContext) {
257
+ opContext.deadCheck('sct:textIndex');
258
+ }
259
+ mark.fill(0);
260
+ for (const id of perToken[t].ids) {
261
+ for (const ordinal of this.postings[id]) {
262
+ mark[ordinal] = 1;
263
+ }
264
+ }
265
+ candidates = candidates.filter(o => mark[o] === 1);
266
+ }
267
+ return candidates;
268
+ }
269
+ }
270
+
271
+ /**
272
+ * Rank the matched concepts and turn them into SnomedFilterContext matches.
273
+ *
274
+ * Coarse rank for all: the share of the concept's words that the filter
275
+ * accounts for (a concept whose terms are just "appendicitis" beats one that
276
+ * mentions it in passing). The top RANK_REFINE_LIMIT are then checked
277
+ * against their active description terms: exact term 100, term starting with
278
+ * the filter 50, all filter tokens within a single description 30, else 10 -
279
+ * plus the coarse share as a tie-break.
280
+ *
281
+ * @param {object} sct - SnomedServices
282
+ * @param {SnomedTextIndex} index
283
+ * @param {number[]} ordinals
284
+ * @param {string} filterText - lower-cased, trimmed filter
285
+ * @param {string[]} tokens
286
+ * @param {boolean} includeInactive
287
+ * @returns {{index:number, term:bigint, priority:number}[]}
288
+ */
289
+ function rankMatches(sct, index, ordinals, filterText, tokens, includeInactive) {
290
+ const size = index.conceptSize;
291
+ const conceptMaster = sct.concepts.master;
292
+ const wordCounts = index.wordCounts;
293
+
294
+ // Active concepts only (unless asked otherwise). The flags byte is read
295
+ // straight from the concept record: getConcept() builds an object per
296
+ // concept, and a one-word filter can match 100k+ of them.
297
+ const kept = [];
298
+ for (const ordinal of ordinals) {
299
+ if (!includeInactive && (conceptMaster.readUInt8(ordinal * size + 8) & 0x0F) !== 0) {
300
+ continue;
301
+ }
302
+ kept.push(ordinal);
303
+ }
304
+ if (kept.length === 0) {
305
+ return [];
306
+ }
307
+
308
+ // Coarse rank: the share of the concept's distinct words that the filter
309
+ // accounts for - a concept whose terms are little more than the filter
310
+ // beats one that mentions it in passing. Since the filter is fixed, that
311
+ // share is decided by the concept's word count, so the best
312
+ // RANK_REFINE_LIMIT are picked by counting sort on the word count rather
313
+ // than by sorting every match.
314
+ const counts = new Map();
315
+ for (const ordinal of kept) {
316
+ const c = wordCounts[ordinal] || 1;
317
+ counts.set(c, (counts.get(c) || 0) + 1);
318
+ }
319
+ const distinct = [...counts.keys()].sort((a, b) => a - b);
320
+ let cutoff = distinct[distinct.length - 1];
321
+ let running = 0;
322
+ for (const c of distinct) {
323
+ running += counts.get(c);
324
+ if (running >= RANK_REFINE_LIMIT) {
325
+ cutoff = c;
326
+ break;
327
+ }
328
+ }
329
+
330
+ const head = [];
331
+ const tail = [];
332
+ for (const ordinal of kept) {
333
+ if ((wordCounts[ordinal] || 1) <= cutoff) {
334
+ head.push(ordinal);
335
+ } else {
336
+ tail.push(ordinal);
337
+ }
338
+ }
339
+
340
+ const normalizedFilter = tokenize(filterText).join(' ');
341
+ const tokenStems = tokens.map(t => stemmer.stem(t));
342
+ const descMaster = sct.descriptions.master;
343
+ const scored = head.map(ordinal => {
344
+ const reference = ordinal * size;
345
+ const entry = {
346
+ index: reference,
347
+ ordinal,
348
+ base: 10,
349
+ termLength: Number.MAX_SAFE_INTEGER,
350
+ share: Math.min(1, tokens.length / (wordCounts[ordinal] || 1))
351
+ };
352
+ const descriptionsRef = sct.concepts.getDescriptions(reference);
353
+ if (!descriptionsRef) {
354
+ return entry;
355
+ }
356
+ for (const descIndex of sct.refs.getReferences(descriptionsRef)) {
357
+ if (descMaster.readUInt8(descIndex + 4) === 0) {
358
+ continue;
359
+ }
360
+ const termTokens = tokenize(sct.strings.getEntry(descMaster.readUInt32LE(descIndex)));
361
+ const normalizedTerm = termTokens.join(' ');
362
+ let base;
363
+ if (normalizedTerm === normalizedFilter) {
364
+ base = 100;
365
+ } else if (normalizedTerm.startsWith(normalizedFilter)) {
366
+ base = 50;
367
+ } else if (tokenStems.every((stem, t) => termTokens.some(w => w === tokens[t] || stemmer.stem(w) === stem))) {
368
+ base = 30;
369
+ } else {
370
+ continue;
371
+ }
372
+ // Best term wins; between equally good terms, the shorter one - it is
373
+ // the more specific match ("Diabetes mellitus" over "Diabetes mellitus
374
+ // screening declined").
375
+ if (base > entry.base || (base === entry.base && normalizedTerm.length < entry.termLength)) {
376
+ entry.base = base;
377
+ entry.termLength = normalizedTerm.length;
378
+ }
379
+ }
380
+ return entry;
381
+ });
382
+
383
+ scored.sort((a, b) =>
384
+ b.base - a.base ||
385
+ a.termLength - b.termLength ||
386
+ b.share - a.share ||
387
+ a.ordinal - b.ordinal);
388
+
389
+ const matches = new Array(scored.length + tail.length);
390
+ let at = 0;
391
+ for (const entry of scored) {
392
+ matches[at++] = {
393
+ index: entry.index,
394
+ term: sct.concepts.getConceptId(entry.index),
395
+ priority: entry.base + entry.share
396
+ };
397
+ }
398
+ // Everything else keeps concept order: these are the matches whose terms are
399
+ // long enough that the filter is a small part of them, and ranking them
400
+ // against each other would cost a pass over the whole match set for a
401
+ // difference no caller sees (count/offset paging stops long before).
402
+ for (const ordinal of tail) {
403
+ const reference = ordinal * size;
404
+ matches[at++] = {
405
+ index: reference,
406
+ term: sct.concepts.getConceptId(reference),
407
+ priority: 10
408
+ };
409
+ }
410
+ return matches;
411
+ }
412
+
413
+ module.exports = {
414
+ SnomedTextIndex,
415
+ rankMatches,
416
+ filterTokens,
417
+ tokenize,
418
+ STOP_WORDS
419
+ };
@@ -861,6 +861,14 @@ class ValueSetExpander {
861
861
  this.addToTotal();
862
862
  }
863
863
  }
864
+ if (this.pageFilled()) {
865
+ // No total is reported for this path (noTotal above), so once
866
+ // the requested page is full there is nothing left to learn
867
+ // from the rest of the matches - and a text filter on a large
868
+ // code system can match tens of thousands of them, each
869
+ // costing a designations/properties lookup.
870
+ break;
871
+ }
864
872
  }
865
873
  this.worker.opContext.log('iterate filters done');
866
874
  }
@@ -1792,6 +1800,18 @@ class ValueSetExpander {
1792
1800
  pdv[valueName] = value;
1793
1801
  }
1794
1802
 
1803
+ /**
1804
+ * True when the requested page (offset + count) has been filled, so an
1805
+ * iteration that is not counting a total can stop. Exclusions are handled
1806
+ * after the fact and can remove codes already collected, so a value set
1807
+ * with any exclusion never stops early.
1808
+ * @returns {boolean}
1809
+ */
1810
+ pageFilled() {
1811
+ return this.count > 0 && this.offset > -1 && !this.hasExclusions
1812
+ && this.fullList.length >= this.count + this.offset;
1813
+ }
1814
+
1795
1815
  addToTotal(t = 1) {
1796
1816
  if (this.total > -1 && this.totalStatus != "off") {
1797
1817
  this.total = this.total + t;
@@ -429,22 +429,34 @@ class MetadataHandler {
429
429
  name: 'tx-resource',
430
430
  documentation: 'Additional valuesets needed for evaluation e.g. value sets referred to from the import statement of the value set being expanded'
431
431
  },
432
+ // Parameters the $expand handler reads - see handleTypeLevelExpand in
433
+ // tx/workers/expand.js and TxParameters.seeParameter in tx/params.js.
434
+ // Keep this list in step with those when adding a parameter.
432
435
  { name: '_incomplete' },
433
436
  { name: 'abstract' },
434
437
  { name: 'activeOnly' },
435
438
  { name: 'check-system-version' },
439
+ { name: 'check-valueset-version' },
436
440
  { name: 'count' },
437
441
  { name: 'default-to-latest-version' },
442
+ { name: 'default-valueset-version' },
443
+ { name: 'designation' },
438
444
  { name: 'displayLanguage' },
439
445
  { name: 'excludeNested' },
440
446
  { name: 'excludeNotForUI' },
441
447
  { name: 'excludePostCoordinated' },
448
+ { name: 'filter' },
442
449
  { name: 'force-system-version' },
450
+ { name: 'force-valueset-version' },
443
451
  { name: 'inactive' },
444
452
  { name: 'includeAlternateCodes' },
445
453
  { name: 'includeDefinition' },
446
454
  { name: 'includeDesignations' },
447
455
  { name: 'incomplete-ok' },
456
+ {
457
+ name: 'limit',
458
+ documentation: 'Upper bound on the size of the expansion; the operation fails as too-costly beyond it'
459
+ },
448
460
  { name: 'limitedExpansion' },
449
461
  {
450
462
  name: 'mode',
@@ -454,11 +466,19 @@ class MetadataHandler {
454
466
  { name: 'offset' },
455
467
  { name: 'profile' },
456
468
  { name: 'property' },
469
+ {
470
+ name: 'sort',
471
+ documentation: 'code | display | design | prop:{name}; prefix with - to reverse. Ignored for hierarchical expansions'
472
+ },
457
473
  { name: 'system-version' },
474
+ { name: 'url' },
475
+ { name: 'useSupplement' },
476
+ { name: 'valueSet' },
458
477
  {
459
478
  name: 'valueSetMode',
460
479
  documentation: '= CHECK_MEMBERSHIP_ONLY | NO_MEMBERSHIP_CHECK'
461
- }
480
+ },
481
+ { name: 'valueSetVersion' }
462
482
  ]
463
483
  };
464
484
  }