Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 11 additions & 9 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
node_modules
npm-debug.log
.git
.gitignore
logs
data
package-cache
*.md
.env
node_modules
npm-debug.log
.git
.gitignore
logs
data
package-cache
*.md
# the release dates of FHIRsmith versions (CapabilityStatement.software.releaseDate, the registry)
!CHANGELOG.md
.env
.env.*
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,33 @@ All notable changes to Health Intersections FHIRsmith will be documented in this
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.15.0] - 2026-mm-dd

### Security

-

### Added

- Library YAML: a source can give the id prefix for the resources it loads (`npm:hl7.terminology=tho`), so their ids stay the same when sources are added, removed or reordered. Sources without one are numbered, as before. See Resource Ids in tx/README.md
- `CapabilityStatement.software.releaseDate` (and the TerminologyCapabilities equivalent) is the date the running version was released, taken from its heading in CHANGELOG.md, which is now included in the Docker image. A snapshot reports the time the server started
- Registry: the crawler records each server's `software.releaseDate`. The Software page uses it to date software other than FHIRsmith, and FHIRsmith releases (including development builds) that the release list doesn't know about yet

### Changed

- **Breaking - resource ids:** every CodeSystem, ValueSet and ConceptMap is served with an id in the id space of its source - `prefix-id`, e.g. `CodeSystem/tho-v3-ActCode` - and the FHIR core package each endpoint loads uses `core` (`CodeSystem/core-administrative-gender`). An unprefixed id is not found by read or by any `[type]/[id]/$operation`. Library CodeSystems used to keep the ids they had in their packages (ValueSets and ConceptMaps were already numbered), and so could clash with the endpoint's core CodeSystems
- THO overrides the FHIR core packages the same way the Java validator does: where hl7.terminology has a url, the core package's CodeSystems, ValueSets and ConceptMaps with that url are not loaded at all. Older core versions used to stay available by `url|version`, so the server reported versions the validator doesn't know (e.g. v3-MaritalStatus `2018-08-12`). The exception is the R4 v2 tables 0006, 0360 and 0391, which R4 core has in two versions each with the version in the url: those versions stay available, and THO's is the default
- `software.releaseDate` is no longer the time of the request

### Fixed

- Search: two CodeSystems with the same id from different packages (e.g. v3-MaritalStatus from hl7.fhir.r4.core and hl7.terminology) linked to the same resource, and a read of that id always returned the core one
- On the R4 endpoint, a ValueSet asked for without a version (e.g. `http://terminology.hl7.org/ValueSet/v3-MaritalStatus`) was the core package's copy rather than hl7.terminology's

### Tx Conformance Statement

(paste)

## [0.14.2] - 2026-10-06

### Security
Expand Down
57 changes: 57 additions & 0 deletions library/changelog.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// library/changelog.js
// Release dates from CHANGELOG.md. A release is dated in its heading when it is made -
// "## [0.14.2] - 2026-10-06" (or "## [v0.13.4] - ...") - so the changelog that ships with
// the code is where a server finds out when its own version was released. A snapshot's
// heading has no date yet, so it has no release date.

const fs = require('fs');
const path = require('path');

const DEFAULT_PATH = path.join(__dirname, '..', 'CHANGELOG.md');
const HEADING = /^##\s*\[v?(\d+\.\d+\.\d+)\]\s*-\s*(\d{4}-\d{2}-\d{2})\s*$/gm;

/**
* @param {string} [changelogPath]
* @returns {Map<string, string>} version (no leading v) -> release date (YYYY-MM-DD).
* Throws if the file can't be read.
*/
function readReleaseDates(changelogPath = DEFAULT_PATH) {
const text = fs.readFileSync(changelogPath, 'utf8');
const dates = new Map();
let m;
HEADING.lastIndex = 0;
while ((m = HEADING.exec(text)) !== null) {
if (!dates.has(m[1])) {
dates.set(m[1], m[2]);
}
}
return dates;
}

/**
* The release date of a version, or null if it isn't a dated release (a snapshot, or no
* changelog to read)
*/
function releaseDateOf(version, changelogPath = DEFAULT_PATH) {
try {
return readReleaseDates(changelogPath).get(String(version || '').replace(/^v/, '')) || null;
} catch (e) {
return null;
}
}

/**
* The release date a server reports for its version: the date of the release, or, for a
* version that hasn't been released (a snapshot), the time the server started - which is
* when that build went into service.
*
* @param {string} version
* @param {Date} started - when the server started
* @param {string} [changelogPath]
* @returns {string} YYYY-MM-DD for a release, an ISO dateTime for a snapshot
*/
function reportedReleaseDate(version, started, changelogPath = DEFAULT_PATH) {
return releaseDateOf(version, changelogPath) || started.toISOString();
}

module.exports = { readReleaseDates, releaseDateOf, reportedReleaseDate };
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "fhirsmith",
"version": "0.14.2",
"version": "0.15.0-snapshot",
"txVersion": "1.9.5-SNAPSHOT",
"description": "A Node.js server that provides a collection of tools to serve the FHIR ecosystem",
"main": "server.js",
Expand Down
17 changes: 17 additions & 0 deletions registry/crawler.js
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,20 @@ class RegistryCrawler {
return version;
}

/**
* CapabilityStatement.software.releaseDate, if it means anything. A date within a few
* minutes of now is the server's clock, not a release date: FHIRsmith up to 0.14.2 filled
* it in with the time of the request, and other software may do the same.
*/
reportedReleaseDate(software, now = Date.now()) {
const value = software && software.releaseDate;
const t = value ? new Date(value).getTime() : NaN;
if (isNaN(t) || Math.abs(now - t) < 10 * 60 * 1000) {
return '';
}
return String(value);
}

/**
* A server that can't be reached this time is still running whatever it was running
* last time we saw it - keep that, so the software page doesn't lose track of it
Expand All @@ -353,6 +367,7 @@ class RegistryCrawler {
if (prev.address === version.address && prev.software) {
version.software = prev.software;
version.softwareVersion = prev.softwareVersion || '';
version.softwareReleaseDate = prev.softwareReleaseDate || '';
return;
}
}
Expand All @@ -371,6 +386,7 @@ class RegistryCrawler {
version.version = capability.fhirVersion || '3.0.2';
version.software = capability.software ? capability.software.name : "unknown";
version.softwareVersion = capability.software && capability.software.version ? String(capability.software.version) : '';
version.softwareReleaseDate = this.reportedReleaseDate(capability.software);

// Get terminology capabilities (R3 uses Parameters resource)
try {
Expand Down Expand Up @@ -416,6 +432,7 @@ class RegistryCrawler {
version.version = capability.fhirVersion || defVersion;
version.software = capability.software ? capability.software.name : "unknown";
version.softwareVersion = capability.software && capability.software.version ? String(capability.software.version) : '';
version.softwareReleaseDate = this.reportedReleaseDate(capability.software);

let set = new Set();

Expand Down
9 changes: 3 additions & 6 deletions registry/fhirsmith-releases.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
// succeeds (or if it can't be reached), the dated headings in this server's own
// CHANGELOG.md are used instead - those only go up to this server's own version.

const fs = require('fs');
const { readReleaseDates } = require('../library/changelog');
const path = require('path');
const axios = require('axios');

Expand Down Expand Up @@ -72,12 +72,9 @@ class FhirsmithReleases {
*/
loadFromChangelog(changelogPath = path.join(__dirname, '..', 'CHANGELOG.md')) {
try {
const text = fs.readFileSync(changelogPath, 'utf8');
const list = [];
const re = /^##\s*\[v?(\d+\.\d+\.\d+)\]\s*-\s*(\d{4}-\d{2}-\d{2})\s*$/gm;
let m;
while ((m = re.exec(text)) !== null) {
list.push({ version: m[1], date: new Date(m[2] + 'T00:00:00Z') });
for (const [version, date] of readReleaseDates(changelogPath)) {
list.push({ version, date: new Date(date + 'T00:00:00Z') });
}
this.setReleases(list, 'CHANGELOG.md');
} catch (error) {
Expand Down
3 changes: 3 additions & 0 deletions registry/model.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ class ServerVersionInformation {
this.lastTat = '';
this.software = ''; // what software is running
this.softwareVersion = ''; // CapabilityStatement.software.version, if the server reports one
this.softwareReleaseDate = ''; // CapabilityStatement.software.releaseDate, if it is a real date
this.codeSystems = []; // Array of strings (sorted, unique)
this.valueSets = []; // Array of strings (sorted, unique)
}
Expand Down Expand Up @@ -60,6 +61,7 @@ class ServerVersionInformation {
terminologies: this.codeSystems,
software: this.software,
'software-version': this.softwareVersion,
'software-release-date': this.softwareReleaseDate,
valuesets: this.valueSets
};
}
Expand All @@ -74,6 +76,7 @@ class ServerVersionInformation {
instance.lastTat = json.lastTat || '';
instance.software = json.software;
instance.softwareVersion = json['software-version'] || '';
instance.softwareReleaseDate = json['software-release-date'] || '';
instance.codeSystems = json.terminologies || [];
instance.valueSets = json.valuesets || [];
return instance;
Expand Down
24 changes: 21 additions & 3 deletions registry/registry.js
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,7 @@ class RegistryModule {
fhirVersion: version.version || '',
software: version.software && version.software !== 'unknown' ? version.software : '',
softwareVersion: version.softwareVersion || '',
softwareReleaseDate: version.softwareReleaseDate || '',
error: version.error,
lastSuccess: version.lastSuccess
});
Expand Down Expand Up @@ -556,7 +557,7 @@ class RegistryModule {

_renderReleaseCells(row, now) {
if (!isFhirsmith(row.software) || !this.releases) {
return '<td></td><td></td>';
return this._renderReportedReleaseCells(row, now);
}
const d = this.releases.describe(row.softwareVersion, now);
const behind = d.behind ? ` (${d.behind} release${d.behind === 1 ? '' : 's'} behind)` : '';
Expand All @@ -571,11 +572,28 @@ class RegistryModule {
`<td><span class="${cls}">${describeAge(d.ageDays)}${behind}</span></td>`;
}
case 'dev':
return '<td></td><td>development build' +
return `<td>${this._reportedReleaseDay(row)}</td><td>development build` +
(d.release ? ` after v${escape(d.release.version)}` : '') + behind + '</td>';
default:
return '<td></td><td><i>unknown</i></td>';
return this._renderReportedReleaseCells(row, now);
}
}

// what the server says about its release (CapabilityStatement.software.releaseDate) -
// the only source for software other than FHIRsmith
_reportedReleaseDay(row) {
// the day as the server gives it - converting to UTC can move it a day
const m = /^(\d{4}-\d{2}-\d{2})/.exec(row.softwareReleaseDate || '');
return m ? m[1] : '';
}

_renderReportedReleaseCells(row, now) {
const day = this._reportedReleaseDay(row);
if (!day) {
return '<td></td><td></td>';
}
const days = Math.max(0, Math.floor((now - new Date(row.softwareReleaseDate).getTime()) / (24 * 60 * 60 * 1000)));
return `<td>${day}</td><td>${describeAge(days)}</td>`;
}

/**
Expand Down
65 changes: 64 additions & 1 deletion tests/cm/cm-package.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -259,4 +259,67 @@ describe('PackageConceptMapProvider', () => {
expect(mapSize).toBeGreaterThanOrEqual(stats.totalConceptMaps);
});
});
});

describe('id space', () => {
let spaced;

beforeAll(async () => {
// a provider of its own, so the other tests see the concept maps as they are loaded
spaced = new PackageConceptMapProvider(new PackageContentLoader(path.join(packageCacheDir, packagePath)));
await spaced.initialize();
spaced.assignIds('ch');
});

test('concept maps carry their prefixed id, and are fetched by it', async () => {
const cm = [...spaced.conceptMapMap.values()][0];
expect(cm.id.startsWith('ch-')).toBe(true);
expect(cm.jsonObj.id).toBe(cm.id);
expect(await spaced.fetchConceptMapById(cm.id)).toBe(cm);
});

test('a concept map is not found by its unprefixed id', async () => {
const cm = [...spaced.conceptMapMap.values()][0];
expect(await spaced.fetchConceptMapById(cm.id.substring('ch-'.length))).toBeNull();
});

test('search returns prefixed ids', async () => {
for (const elements of [null, ['id', 'url']]) {
const results = await spaced.searchConceptMaps([], elements);
expect(results.length).toBeGreaterThan(0);
for (const r of results) {
expect(r.id.startsWith('ch-')).toBe(true);
expect(r.id.startsWith('ch-ch-')).toBe(false);
}
}
});
});

describe('excludeUrls', () => {
let reduced;
let gone;

beforeAll(async () => {
reduced = new PackageConceptMapProvider(new PackageContentLoader(path.join(packageCacheDir, packagePath)));
await reduced.initialize();
reduced.assignIds('core');
gone = [...reduced.conceptMapMap.values()][0];
reduced.excludeUrls(new Set([gone.url]));
});

test('an excluded concept map is not fetched, by url or by id', async () => {
expect(await reduced.fetchConceptMap(gone.url, null)).toBeNull();
expect(await reduced.fetchConceptMapById(gone.id)).toBeNull();
});

test('an excluded concept map is not found by search', async () => {
for (const elements of [null, ['id', 'url']]) {
const results = await reduced.searchConceptMaps([], elements);
expect(results.some(r => r.id === gone.id)).toBe(false);
}
});

test('the package says where it came from', () => {
expect(reduced.sourcePackage()).toBe('ch.fhir.ig.ch-core');
});
});
});
Loading
Loading