fhir-terminology-runtime 0.1.0 → 1.0.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.
package/README.md CHANGED
@@ -398,7 +398,7 @@ Consider a large ValueSet `VS` and a code lookup `inValueSet('C10', VS)`:
398
398
  #### Priming is not ValueSet expansion caching
399
399
 
400
400
  FTR has two separate caching concepts:
401
- - **Expansion caching**: stores the expanded ValueSet JSON on disk under `.ftr/` alongside packages.
401
+ - **Expansion caching**: stores the expanded ValueSet JSON on disk under `.ftr.expansions/` alongside package contents.
402
402
  - **Membership caching**: accelerates `inValueSet` lookups (in-memory LRUs + optional external cache).
403
403
 
404
404
  “Priming” only refers to the **external membership cache** behavior.
@@ -422,7 +422,7 @@ When expanding ValueSets the runtime resolves referenced CodeSystems by canonica
422
422
 
423
423
  ### Expansion Caching
424
424
 
425
- Expanded (or fallback) ValueSets are cached in a dedicated `.ftr` directory alongside source packages. Repeated calls reuse the cached expansion unless `cacheMode` is `none`.
425
+ Expanded (or fallback) ValueSets are cached in a dedicated `.ftr.expansions/` directory inside package folders in the FHIR package cache folder. Repeated calls reuse the cached expansion unless `cacheMode` is `none`.
426
426
 
427
427
  ## Context
428
428
  You must provide an array of FHIR packages in `context`. Any package or its dependencies missing in the local FHIR package cache will be downloaded and installed (by [`fhir-package-installer`](https://github.com/Outburn-IL/fhir-package-installer)).
@@ -447,7 +447,7 @@ Supports `<id>#<version>`, `<id>@<version>`, `<id>` (latest version) or a packag
447
447
  Cached artifacts are stored under:
448
448
 
449
449
  ```
450
- <cachePath>/<packageId>#<packageVersion>/.ftr/<FTR version>/
450
+ <cachePath>/<packageId>#<packageVersion>/.ftr.expansions/<FTR version>/
451
451
  ```
452
452
  - Filenames mirror originals in `<cachePath>/<packageId>#<packageVersion>/package`.
453
453
  - FTR Version directory uses major.minor.x (e.g. `0.1.x`).
package/dist/index.cjs CHANGED
@@ -958,36 +958,13 @@ var ImplicitCodeSystemRegistry = class {
958
958
  }
959
959
  };
960
960
 
961
- // src/utils/logger.ts
962
- var defaultLogger = {
963
- info: (msg) => console.log(msg),
964
- warn: (msg) => console.warn(msg),
965
- error: (msg) => console.error(msg)
966
- };
967
- var defaultPrethrow = (msg) => {
968
- if (msg instanceof Error) {
969
- return msg;
970
- }
971
- const error = new Error(msg);
972
- return error;
973
- };
974
- var customPrethrower = (logger) => {
975
- return (msg) => {
976
- if (msg instanceof Error) {
977
- logger.error(msg);
978
- return msg;
979
- }
980
- const error = new Error(msg);
981
- logger.error(error);
982
- return error;
983
- };
984
- };
985
-
986
961
  // package.json
987
- var version = "0.1.0";
962
+ var version = "1.0.0";
988
963
 
989
964
  // src/index.ts
990
965
  var versionedCacheDir = `v${version.split(".").slice(0, 2).join(".")}.x`;
966
+ var FTR_VERSION_EXTENSION_URL = "http://fhir.fume.health/StructureDefinition/ftr-version";
967
+ var FTR_EXPANSION_FAILED_EXTENSION_URL = "http://fhir.fume.health/StructureDefinition/ftr-expansion-failed";
991
968
  var FTR_DEFAULT_LIMITS = {
992
969
  valueSet: {
993
970
  smallThresholdUniqueCodes: 50,
@@ -1033,13 +1010,16 @@ var FhirTerminologyRuntime = class _FhirTerminologyRuntime {
1033
1010
  // Cache for resolving server ConceptMap identifiers (url/id/name) -> deterministic server key.
1034
1011
  // Keyed by baseUrl + identifier.
1035
1012
  this.serverConceptMapIdentifierKeyCache = /* @__PURE__ */ new Map();
1036
- if (logger) {
1037
- this.logger = logger;
1038
- this.prethrow = customPrethrower(this.logger);
1039
- } else {
1040
- this.logger = defaultLogger;
1041
- this.prethrow = defaultPrethrow;
1042
- }
1013
+ this.logger = logger || {
1014
+ debug: () => {
1015
+ },
1016
+ info: () => {
1017
+ },
1018
+ warn: () => {
1019
+ },
1020
+ error: () => {
1021
+ }
1022
+ };
1043
1023
  this.cacheMode = cacheMode;
1044
1024
  this.fhirVersion = fhirVersion;
1045
1025
  this.fpe = fpe;
@@ -1058,55 +1038,49 @@ var FhirTerminologyRuntime = class _FhirTerminologyRuntime {
1058
1038
  * @returns - A promise that resolves to a new instance of the FhirTerminologyRuntime class
1059
1039
  */
1060
1040
  static async create(config) {
1061
- const logger = config.logger || defaultLogger;
1062
- const prethrow = config.logger ? customPrethrower(logger) : defaultPrethrow;
1063
- try {
1064
- const cacheMode = config.cacheMode || "lazy";
1065
- const fhirVersion = config.fhirVersion || "4.0.1";
1066
- const fpe = config.fpe;
1067
- const ftr = new _FhirTerminologyRuntime(
1068
- fpe,
1069
- cacheMode,
1070
- fhirVersion,
1071
- config.logger,
1072
- config.membershipCache,
1073
- config.conceptMapCache,
1074
- config.fhirClient
1075
- );
1076
- let precache = false;
1077
- if (cacheMode === "rebuild") {
1078
- precache = true;
1079
- const packageList = fpe.getContextPackages().map((pkg) => path__default.default.join(fpe.getCachePath(), `${pkg.id}#${pkg.version}`, ".ftr.expansions", versionedCacheDir));
1080
- for (const expansionCacheDir of packageList) {
1081
- if (await fs__default.default.exists(expansionCacheDir)) {
1082
- fs__default.default.removeSync(expansionCacheDir);
1083
- }
1041
+ const cacheMode = config.cacheMode || "lazy";
1042
+ const fhirVersion = config.fhirVersion || "4.0.1";
1043
+ const fpe = config.fpe;
1044
+ const ftr = new _FhirTerminologyRuntime(
1045
+ fpe,
1046
+ cacheMode,
1047
+ fhirVersion,
1048
+ config.logger,
1049
+ config.membershipCache,
1050
+ config.conceptMapCache,
1051
+ config.fhirClient
1052
+ );
1053
+ let precache = false;
1054
+ if (cacheMode === "rebuild") {
1055
+ precache = true;
1056
+ const packageList = fpe.getContextPackages().map((pkg) => path__default.default.join(fpe.getCachePath(), `${pkg.id}#${pkg.version}`, ".ftr.expansions", versionedCacheDir));
1057
+ for (const expansionCacheDir of packageList) {
1058
+ if (await fs__default.default.exists(expansionCacheDir)) {
1059
+ fs__default.default.removeSync(expansionCacheDir);
1084
1060
  }
1085
1061
  }
1086
- if (cacheMode === "ensure") precache = true;
1087
- if (precache) {
1088
- logger.info(`Pre-caching ValueSet expansions in '${cacheMode}' mode...`);
1089
- const vsErrors = [];
1090
- const allVs = await fpe.lookupMeta({ resourceType: "ValueSet" });
1091
- for (const vs of allVs) {
1092
- const { filename, __packageId: packageId, __packageVersion: packageVersion, url } = vs;
1093
- try {
1094
- await ftr.ensureExpansionCached(filename, packageId, packageVersion);
1095
- } catch (e) {
1096
- vsErrors.push(`Failed to ${cacheMode} expansion for '${url || filename}' in package '${packageId}@${packageVersion}': ${e instanceof Error ? e.message : String(e)}`);
1097
- }
1062
+ }
1063
+ if (cacheMode === "ensure") precache = true;
1064
+ if (precache) {
1065
+ ftr.getLogger().info(`Pre-caching ValueSet expansions in '${cacheMode}' mode...`);
1066
+ const vsErrors = [];
1067
+ const allVs = await fpe.lookupMeta({ resourceType: "ValueSet" });
1068
+ for (const vs of allVs) {
1069
+ const { filename, __packageId: packageId, __packageVersion: packageVersion, url } = vs;
1070
+ try {
1071
+ await ftr.ensureExpansionCached(filename, packageId, packageVersion);
1072
+ } catch (e) {
1073
+ vsErrors.push(`Failed to ${cacheMode} expansion for '${url || filename}' in package '${packageId}@${packageVersion}': ${e instanceof Error ? e.message : String(e)}`);
1098
1074
  }
1099
- if (vsErrors.length > 0) {
1100
- logger.warn(`Errors during pre-caching ValueSet expansions (${vsErrors.length} total):
1075
+ }
1076
+ if (vsErrors.length > 0) {
1077
+ ftr.getLogger().warn(`Errors during pre-caching ValueSet expansions (${vsErrors.length} total):
1101
1078
  ${vsErrors.join("\n")}`);
1102
- } else {
1103
- logger.info(`Pre-caching ValueSet expansions in '${cacheMode}' mode completed successfully.`);
1104
- }
1079
+ } else {
1080
+ ftr.getLogger().info(`Pre-caching ValueSet expansions in '${cacheMode}' mode completed successfully.`);
1105
1081
  }
1106
- return ftr;
1107
- } catch (e) {
1108
- throw prethrow(e);
1109
1082
  }
1083
+ return ftr;
1110
1084
  }
1111
1085
  getLogger() {
1112
1086
  return this.logger;
@@ -1130,6 +1104,33 @@ ${vsErrors.join("\n")}`);
1130
1104
  async getValueSetByFileName(filename, packageId, packageVersion) {
1131
1105
  return await this.fpe.resolve({ filename, package: { id: packageId, version: packageVersion } });
1132
1106
  }
1107
+ withFtrVersionExtensionInExpansion(resource) {
1108
+ const expansion = resource?.expansion;
1109
+ if (!expansion || typeof expansion !== "object") return resource;
1110
+ const existing = Array.isArray(expansion.extension) ? expansion.extension : [];
1111
+ const alreadyPresent = existing.some((e) => e?.url === FTR_VERSION_EXTENSION_URL);
1112
+ if (alreadyPresent) return resource;
1113
+ const nextExpansion = {
1114
+ ...expansion,
1115
+ extension: existing.concat([{ url: FTR_VERSION_EXTENSION_URL, valueCode: version }])
1116
+ };
1117
+ return { ...resource, expansion: nextExpansion };
1118
+ }
1119
+ getExtensionValue(expansion, url) {
1120
+ const ext = Array.isArray(expansion?.extension) ? expansion.extension : [];
1121
+ return ext.find((e) => e?.url === url);
1122
+ }
1123
+ getFtrVersionFromExpansion(resource) {
1124
+ const exp = resource?.expansion;
1125
+ const ext = this.getExtensionValue(exp, FTR_VERSION_EXTENSION_URL);
1126
+ return typeof ext?.valueCode === "string" && ext.valueCode.length > 0 ? ext.valueCode : void 0;
1127
+ }
1128
+ isExpansionMarkedFailed(resource) {
1129
+ if (resource?.expansion?.__failure === true) return true;
1130
+ const exp = resource?.expansion;
1131
+ const ext = this.getExtensionValue(exp, FTR_EXPANSION_FAILED_EXTENSION_URL);
1132
+ return ext?.valueBoolean === true;
1133
+ }
1133
1134
  stableStringify(value) {
1134
1135
  const seen = /* @__PURE__ */ new WeakSet();
1135
1136
  const normalize = (v) => {
@@ -1497,10 +1498,14 @@ ${vsErrors.join("\n")}`);
1497
1498
  const { filename, __packageId: packageId, __packageVersion: packageVersion } = metadata;
1498
1499
  const cached = this.cacheMode !== "none" ? await this.getExpansionFromCache(filename, packageId, packageVersion) : void 0;
1499
1500
  if (cached) {
1500
- if (cached?.expansion?.__failure === true) {
1501
- throw new Error(`Previous expansion attempt failed for ValueSet '${cached.url || cached.id || filename}' (cached).`);
1501
+ if (this.isExpansionMarkedFailed(cached)) {
1502
+ const cachedVersion = this.getFtrVersionFromExpansion(cached);
1503
+ if (cachedVersion === version) {
1504
+ throw new Error(`Previous expansion attempt failed for ValueSet '${cached.url || cached.id || filename}' (cached).`);
1505
+ }
1506
+ } else {
1507
+ return this.withFtrVersionExtensionInExpansion(cached);
1502
1508
  }
1503
- return cached;
1504
1509
  }
1505
1510
  const vs = await this.getValueSetByFileName(filename, packageId, packageVersion);
1506
1511
  if (!vs || vs.resourceType !== "ValueSet") throw new Error(`File '${filename}' not found as a ValueSet in package '${packageId}@${packageVersion}'.`);
@@ -1520,7 +1525,15 @@ ${vsErrors.join("\n")}`);
1520
1525
  }
1521
1526
  this.subtractSystemMaps(includeMap, excludeMap);
1522
1527
  const { contains, total } = this.buildExpansionFromSystemMap(includeMap);
1523
- const expanded = { ...vs, expansion: { timestamp: (/* @__PURE__ */ new Date()).toISOString(), total, contains } };
1528
+ const expanded = {
1529
+ ...vs,
1530
+ expansion: {
1531
+ extension: [{ url: FTR_VERSION_EXTENSION_URL, valueCode: version }],
1532
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1533
+ total,
1534
+ contains
1535
+ }
1536
+ };
1524
1537
  if (this.cacheMode !== "none") {
1525
1538
  await this.saveExpansionToCache(filename, packageId, packageVersion, expanded);
1526
1539
  }
@@ -1528,13 +1541,23 @@ ${vsErrors.join("\n")}`);
1528
1541
  } catch (e) {
1529
1542
  this.logger.warn(`Failed to expand ValueSet '${vs?.url || vs?.id || filename}': ${e instanceof Error ? e.message : String(e)}. Falling back to original expansion if present.`);
1530
1543
  if (vs?.expansion?.contains && Array.isArray(vs.expansion.contains)) {
1544
+ const normalized = this.withFtrVersionExtensionInExpansion(vs);
1531
1545
  if (this.cacheMode !== "none") {
1532
- await this.saveExpansionToCache(filename, packageId, packageVersion, vs);
1546
+ await this.saveExpansionToCache(filename, packageId, packageVersion, normalized);
1533
1547
  }
1534
- return vs;
1548
+ return normalized;
1535
1549
  }
1536
1550
  if (this.cacheMode !== "none") {
1537
- const failureStub = { ...vs, expansion: { timestamp: (/* @__PURE__ */ new Date()).toISOString(), __failure: true } };
1551
+ const failureStub = {
1552
+ ...vs,
1553
+ expansion: {
1554
+ extension: [
1555
+ { url: FTR_VERSION_EXTENSION_URL, valueCode: version },
1556
+ { url: FTR_EXPANSION_FAILED_EXTENSION_URL, valueBoolean: true }
1557
+ ],
1558
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
1559
+ }
1560
+ };
1538
1561
  try {
1539
1562
  await this.saveExpansionToCache(filename, packageId, packageVersion, failureStub);
1540
1563
  } catch {
@@ -1561,25 +1584,21 @@ ${vsErrors.join("\n")}`);
1561
1584
  * Get ValueSet expansion by any FSH style identifier (id, url or name), or by a metadata object.
1562
1585
  */
1563
1586
  async expandValueSet(identifier, packageFilter) {
1564
- try {
1565
- let metadata;
1566
- if (typeof identifier === "string") {
1567
- metadata = await this.getValueSetMetadata(identifier, packageFilter);
1568
- if (!metadata) {
1569
- throw new Error(`ValueSet '${identifier}' not found in context. Could not get or generate an expansion.`);
1570
- }
1571
- } else {
1572
- metadata = identifier;
1573
- if (!metadata) {
1574
- throw new Error(`ValueSet with metadata:
1587
+ let metadata;
1588
+ if (typeof identifier === "string") {
1589
+ metadata = await this.getValueSetMetadata(identifier, packageFilter);
1590
+ if (!metadata) {
1591
+ throw new Error(`ValueSet '${identifier}' not found in context. Could not get or generate an expansion.`);
1592
+ }
1593
+ } else {
1594
+ metadata = identifier;
1595
+ if (!metadata) {
1596
+ throw new Error(`ValueSet with metadata:
1575
1597
  ${JSON.stringify(identifier, null, 2)}
1576
1598
  not found in context. Could not get or generate an expansion.`);
1577
- }
1578
1599
  }
1579
- return await this.expandValueSetByMeta(metadata);
1580
- } catch (e) {
1581
- throw this.prethrow(e);
1582
1600
  }
1601
+ return await this.expandValueSetByMeta(metadata);
1583
1602
  }
1584
1603
  /**
1585
1604
  * Get the count of concepts in the expansion of a ValueSet.
@@ -1633,55 +1652,51 @@ not found in context. Could not get or generate an expansion.`);
1633
1652
  * @returns The full CodeSystem resource (content=complete).
1634
1653
  */
1635
1654
  async resolveCompleteCodeSystem(url, sourcePackage) {
1636
- try {
1637
- if (!url) {
1638
- throw new Error("CodeSystem canonical URL missing.");
1639
- }
1640
- if (ImplicitCodeSystemRegistry.isImplicitCodeSystem(url)) {
1641
- const concepts = ImplicitCodeSystemRegistry.getConcepts(url);
1642
- if (!concepts) {
1643
- throw new Error(`Implicit CodeSystem '${url}' provider returned no concepts.`);
1644
- }
1645
- return {
1646
- resourceType: "CodeSystem",
1647
- url,
1648
- status: "active",
1649
- content: "complete",
1650
- concept: Array.from(concepts.entries()).map(([code, display]) => ({
1651
- code,
1652
- display
1653
- }))
1654
- };
1655
+ if (!url) {
1656
+ throw new Error("CodeSystem canonical URL missing.");
1657
+ }
1658
+ if (ImplicitCodeSystemRegistry.isImplicitCodeSystem(url)) {
1659
+ const concepts = ImplicitCodeSystemRegistry.getConcepts(url);
1660
+ if (!concepts) {
1661
+ throw new Error(`Implicit CodeSystem '${url}' provider returned no concepts.`);
1655
1662
  }
1656
- let meta;
1663
+ return {
1664
+ resourceType: "CodeSystem",
1665
+ url,
1666
+ status: "active",
1667
+ content: "complete",
1668
+ concept: Array.from(concepts.entries()).map(([code, display]) => ({
1669
+ code,
1670
+ display
1671
+ }))
1672
+ };
1673
+ }
1674
+ let meta;
1675
+ try {
1676
+ meta = await this.resolveMetaCached({ resourceType: "CodeSystem", url, package: sourcePackage });
1677
+ } catch {
1678
+ }
1679
+ if (!meta) {
1657
1680
  try {
1658
- meta = await this.resolveMetaCached({ resourceType: "CodeSystem", url, package: sourcePackage });
1681
+ meta = await this.resolveMetaCached({ resourceType: "CodeSystem", url });
1659
1682
  } catch {
1683
+ throw new Error(`CodeSystem '${url}' not found (searched in package '${sourcePackage.id}@${sourcePackage.version}' then globally).`);
1660
1684
  }
1661
- if (!meta) {
1662
- try {
1663
- meta = await this.resolveMetaCached({ resourceType: "CodeSystem", url });
1664
- } catch {
1665
- throw new Error(`CodeSystem '${url}' not found (searched in package '${sourcePackage.id}@${sourcePackage.version}' then globally).`);
1666
- }
1667
- }
1668
- if (!meta?.content || typeof meta?.content === "string" && meta.content !== "complete") {
1669
- throw new Error(`CodeSystem '${url}' has content='${meta.content}' and cannot be expanded (only 'complete' supported).`);
1670
- }
1671
- const cs = await this.fpe.resolve({ filename: meta.filename, package: { id: meta.__packageId, version: meta.__packageVersion } });
1672
- if (!cs) {
1673
- throw new Error(`Failed to load CodeSystem '${url}' from package '${meta.__packageId}@${meta.__packageVersion}'.`);
1674
- }
1675
- if (cs.resourceType !== "CodeSystem") {
1676
- throw new Error(`Resolved resource for '${url}' is not a CodeSystem (got '${cs.resourceType || "unknown"}').`);
1677
- }
1678
- if (cs.content !== "complete") {
1679
- throw new Error(`CodeSystem '${url}' has content='${cs.content || "undefined"}' and cannot be expanded (only 'complete' supported).`);
1680
- }
1681
- return cs;
1682
- } catch (e) {
1683
- throw this.prethrow(e);
1684
1685
  }
1686
+ if (!meta?.content || typeof meta?.content === "string" && meta.content !== "complete") {
1687
+ throw new Error(`CodeSystem '${url}' has content='${meta.content}' and cannot be expanded (only 'complete' supported).`);
1688
+ }
1689
+ const cs = await this.fpe.resolve({ filename: meta.filename, package: { id: meta.__packageId, version: meta.__packageVersion } });
1690
+ if (!cs) {
1691
+ throw new Error(`Failed to load CodeSystem '${url}' from package '${meta.__packageId}@${meta.__packageVersion}'.`);
1692
+ }
1693
+ if (cs.resourceType !== "CodeSystem") {
1694
+ throw new Error(`Resolved resource for '${url}' is not a CodeSystem (got '${cs.resourceType || "unknown"}').`);
1695
+ }
1696
+ if (cs.content !== "complete") {
1697
+ throw new Error(`CodeSystem '${url}' has content='${cs.content || "undefined"}' and cannot be expanded (only 'complete' supported).`);
1698
+ }
1699
+ return cs;
1685
1700
  }
1686
1701
  /**
1687
1702
  * Check whether a code (string) or Coding-like object is a member of a ValueSet.
@@ -1883,7 +1898,7 @@ not found in context. Could not get or generate an expansion.`);
1883
1898
  if (!cmKey) {
1884
1899
  const err = new Error(`ConceptMap '${typeof conceptMap === "string" ? conceptMap : conceptMap?.filename || "unknown"}' could not be resolved.`);
1885
1900
  err.errors = errors;
1886
- throw this.prethrow(err);
1901
+ throw err;
1887
1902
  }
1888
1903
  const cmKeyStr = this.toConceptMapKeyString(cmKey);
1889
1904
  const smallIndex = this.smallConceptMapIndexLru.get(cmKeyStr);
@@ -1917,12 +1932,12 @@ not found in context. Could not get or generate an expansion.`);
1917
1932
  } catch (e) {
1918
1933
  const err = new Error(`Failed to load ConceptMap for key '${cmKeyStr}'.`);
1919
1934
  err.errors = errors.concat([e]);
1920
- throw this.prethrow(err);
1935
+ throw err;
1921
1936
  }
1922
1937
  if (!cm || cm.resourceType !== "ConceptMap") {
1923
1938
  const err = new Error(`Resolved ConceptMap '${cmKeyStr}' is not a ConceptMap.`);
1924
1939
  err.errors = errors;
1925
- throw this.prethrow(err);
1940
+ throw err;
1926
1941
  }
1927
1942
  const flatIndex = buildIndexFromConceptMap(cm);
1928
1943
  if (flatIndex.uniqueSourceCodeCount <= FTR_DEFAULT_LIMITS.conceptMap.smallThresholdUniqueSourceCodes) {