fhirsmith 0.14.2 → 0.15.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/CHANGELOG.md CHANGED
@@ -5,6 +5,29 @@ All notable changes to Health Intersections FHIRsmith will be documented in this
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
+ ## [0.15.0] - 2026-10-08
9
+
10
+ ### Added
11
+
12
+ - 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
13
+ - `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
14
+ - 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
15
+
16
+ ### Changed
17
+
18
+ - **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
19
+ - 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
20
+ - `software.releaseDate` is no longer the time of the request
21
+
22
+ ### Fixed
23
+
24
+ - 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
25
+ - 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
26
+
27
+ ### Tx Conformance Statement
28
+
29
+ FHIRsmith v0.15.0 passed all 3585 HL7 terminology service tests (modes tx.fhir.org+omop+general+snomed+mimetypes+icd-11+closure, tests v1.9.6, runner v7.0.0)
30
+
8
31
  ## [0.14.2] - 2026-10-06
9
32
 
10
33
  ### Security
@@ -0,0 +1,57 @@
1
+ // library/changelog.js
2
+ // Release dates from CHANGELOG.md. A release is dated in its heading when it is made -
3
+ // "## [0.14.2] - 2026-10-06" (or "## [v0.13.4] - ...") - so the changelog that ships with
4
+ // the code is where a server finds out when its own version was released. A snapshot's
5
+ // heading has no date yet, so it has no release date.
6
+
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+
10
+ const DEFAULT_PATH = path.join(__dirname, '..', 'CHANGELOG.md');
11
+ const HEADING = /^##\s*\[v?(\d+\.\d+\.\d+)\]\s*-\s*(\d{4}-\d{2}-\d{2})\s*$/gm;
12
+
13
+ /**
14
+ * @param {string} [changelogPath]
15
+ * @returns {Map<string, string>} version (no leading v) -> release date (YYYY-MM-DD).
16
+ * Throws if the file can't be read.
17
+ */
18
+ function readReleaseDates(changelogPath = DEFAULT_PATH) {
19
+ const text = fs.readFileSync(changelogPath, 'utf8');
20
+ const dates = new Map();
21
+ let m;
22
+ HEADING.lastIndex = 0;
23
+ while ((m = HEADING.exec(text)) !== null) {
24
+ if (!dates.has(m[1])) {
25
+ dates.set(m[1], m[2]);
26
+ }
27
+ }
28
+ return dates;
29
+ }
30
+
31
+ /**
32
+ * The release date of a version, or null if it isn't a dated release (a snapshot, or no
33
+ * changelog to read)
34
+ */
35
+ function releaseDateOf(version, changelogPath = DEFAULT_PATH) {
36
+ try {
37
+ return readReleaseDates(changelogPath).get(String(version || '').replace(/^v/, '')) || null;
38
+ } catch (e) {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * The release date a server reports for its version: the date of the release, or, for a
45
+ * version that hasn't been released (a snapshot), the time the server started - which is
46
+ * when that build went into service.
47
+ *
48
+ * @param {string} version
49
+ * @param {Date} started - when the server started
50
+ * @param {string} [changelogPath]
51
+ * @returns {string} YYYY-MM-DD for a release, an ISO dateTime for a snapshot
52
+ */
53
+ function reportedReleaseDate(version, started, changelogPath = DEFAULT_PATH) {
54
+ return releaseDateOf(version, changelogPath) || started.toISOString();
55
+ }
56
+
57
+ module.exports = { readReleaseDates, releaseDateOf, reportedReleaseDate };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fhirsmith",
3
- "version": "0.14.2",
3
+ "version": "0.15.0",
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",
@@ -339,6 +339,20 @@ class RegistryCrawler {
339
339
  return version;
340
340
  }
341
341
 
342
+ /**
343
+ * CapabilityStatement.software.releaseDate, if it means anything. A date within a few
344
+ * minutes of now is the server's clock, not a release date: FHIRsmith up to 0.14.2 filled
345
+ * it in with the time of the request, and other software may do the same.
346
+ */
347
+ reportedReleaseDate(software, now = Date.now()) {
348
+ const value = software && software.releaseDate;
349
+ const t = value ? new Date(value).getTime() : NaN;
350
+ if (isNaN(t) || Math.abs(now - t) < 10 * 60 * 1000) {
351
+ return '';
352
+ }
353
+ return String(value);
354
+ }
355
+
342
356
  /**
343
357
  * A server that can't be reached this time is still running whatever it was running
344
358
  * last time we saw it - keep that, so the software page doesn't lose track of it
@@ -353,6 +367,7 @@ class RegistryCrawler {
353
367
  if (prev.address === version.address && prev.software) {
354
368
  version.software = prev.software;
355
369
  version.softwareVersion = prev.softwareVersion || '';
370
+ version.softwareReleaseDate = prev.softwareReleaseDate || '';
356
371
  return;
357
372
  }
358
373
  }
@@ -371,6 +386,7 @@ class RegistryCrawler {
371
386
  version.version = capability.fhirVersion || '3.0.2';
372
387
  version.software = capability.software ? capability.software.name : "unknown";
373
388
  version.softwareVersion = capability.software && capability.software.version ? String(capability.software.version) : '';
389
+ version.softwareReleaseDate = this.reportedReleaseDate(capability.software);
374
390
 
375
391
  // Get terminology capabilities (R3 uses Parameters resource)
376
392
  try {
@@ -416,6 +432,7 @@ class RegistryCrawler {
416
432
  version.version = capability.fhirVersion || defVersion;
417
433
  version.software = capability.software ? capability.software.name : "unknown";
418
434
  version.softwareVersion = capability.software && capability.software.version ? String(capability.software.version) : '';
435
+ version.softwareReleaseDate = this.reportedReleaseDate(capability.software);
419
436
 
420
437
  let set = new Set();
421
438
 
@@ -6,7 +6,7 @@
6
6
  // succeeds (or if it can't be reached), the dated headings in this server's own
7
7
  // CHANGELOG.md are used instead - those only go up to this server's own version.
8
8
 
9
- const fs = require('fs');
9
+ const { readReleaseDates } = require('../library/changelog');
10
10
  const path = require('path');
11
11
  const axios = require('axios');
12
12
 
@@ -72,12 +72,9 @@ class FhirsmithReleases {
72
72
  */
73
73
  loadFromChangelog(changelogPath = path.join(__dirname, '..', 'CHANGELOG.md')) {
74
74
  try {
75
- const text = fs.readFileSync(changelogPath, 'utf8');
76
75
  const list = [];
77
- const re = /^##\s*\[v?(\d+\.\d+\.\d+)\]\s*-\s*(\d{4}-\d{2}-\d{2})\s*$/gm;
78
- let m;
79
- while ((m = re.exec(text)) !== null) {
80
- list.push({ version: m[1], date: new Date(m[2] + 'T00:00:00Z') });
76
+ for (const [version, date] of readReleaseDates(changelogPath)) {
77
+ list.push({ version, date: new Date(date + 'T00:00:00Z') });
81
78
  }
82
79
  this.setReleases(list, 'CHANGELOG.md');
83
80
  } catch (error) {
package/registry/model.js CHANGED
@@ -12,6 +12,7 @@ class ServerVersionInformation {
12
12
  this.lastTat = '';
13
13
  this.software = ''; // what software is running
14
14
  this.softwareVersion = ''; // CapabilityStatement.software.version, if the server reports one
15
+ this.softwareReleaseDate = ''; // CapabilityStatement.software.releaseDate, if it is a real date
15
16
  this.codeSystems = []; // Array of strings (sorted, unique)
16
17
  this.valueSets = []; // Array of strings (sorted, unique)
17
18
  }
@@ -60,6 +61,7 @@ class ServerVersionInformation {
60
61
  terminologies: this.codeSystems,
61
62
  software: this.software,
62
63
  'software-version': this.softwareVersion,
64
+ 'software-release-date': this.softwareReleaseDate,
63
65
  valuesets: this.valueSets
64
66
  };
65
67
  }
@@ -74,6 +76,7 @@ class ServerVersionInformation {
74
76
  instance.lastTat = json.lastTat || '';
75
77
  instance.software = json.software;
76
78
  instance.softwareVersion = json['software-version'] || '';
79
+ instance.softwareReleaseDate = json['software-release-date'] || '';
77
80
  instance.codeSystems = json.terminologies || [];
78
81
  instance.valueSets = json.valuesets || [];
79
82
  return instance;
@@ -512,6 +512,7 @@ class RegistryModule {
512
512
  fhirVersion: version.version || '',
513
513
  software: version.software && version.software !== 'unknown' ? version.software : '',
514
514
  softwareVersion: version.softwareVersion || '',
515
+ softwareReleaseDate: version.softwareReleaseDate || '',
515
516
  error: version.error,
516
517
  lastSuccess: version.lastSuccess
517
518
  });
@@ -556,7 +557,7 @@ class RegistryModule {
556
557
 
557
558
  _renderReleaseCells(row, now) {
558
559
  if (!isFhirsmith(row.software) || !this.releases) {
559
- return '<td></td><td></td>';
560
+ return this._renderReportedReleaseCells(row, now);
560
561
  }
561
562
  const d = this.releases.describe(row.softwareVersion, now);
562
563
  const behind = d.behind ? ` (${d.behind} release${d.behind === 1 ? '' : 's'} behind)` : '';
@@ -571,11 +572,28 @@ class RegistryModule {
571
572
  `<td><span class="${cls}">${describeAge(d.ageDays)}${behind}</span></td>`;
572
573
  }
573
574
  case 'dev':
574
- return '<td></td><td>development build' +
575
+ return `<td>${this._reportedReleaseDay(row)}</td><td>development build` +
575
576
  (d.release ? ` after v${escape(d.release.version)}` : '') + behind + '</td>';
576
577
  default:
577
- return '<td></td><td><i>unknown</i></td>';
578
+ return this._renderReportedReleaseCells(row, now);
579
+ }
580
+ }
581
+
582
+ // what the server says about its release (CapabilityStatement.software.releaseDate) -
583
+ // the only source for software other than FHIRsmith
584
+ _reportedReleaseDay(row) {
585
+ // the day as the server gives it - converting to UTC can move it a day
586
+ const m = /^(\d{4}-\d{2}-\d{2})/.exec(row.softwareReleaseDate || '');
587
+ return m ? m[1] : '';
588
+ }
589
+
590
+ _renderReportedReleaseCells(row, now) {
591
+ const day = this._reportedReleaseDay(row);
592
+ if (!day) {
593
+ return '<td></td><td></td>';
578
594
  }
595
+ const days = Math.max(0, Math.floor((now - new Date(row.softwareReleaseDate).getTime()) / (24 * 60 * 60 * 1000)));
596
+ return `<td>${day}</td><td>${describeAge(days)}</td>`;
579
597
  }
580
598
 
581
599
  /**
package/tx/README.md CHANGED
@@ -237,12 +237,28 @@ shouldn't be - too big. Note that that files in the list below are considered to
237
237
  Each source entry follows the format:
238
238
 
239
239
  ```
240
- type[!]:details
240
+ type[!]:details[=prefix]
241
241
  ```
242
242
 
243
243
  - **type**: The source type (see Source Types below)
244
244
  - **!** (optional): Append to type to mark this source as the default for its code system. When multiple versions of the same code system are loaded, the default is used when no version is specified.
245
245
  - **details**: Type-specific configuration (filename, package name, or internal provider name)
246
+ - **prefix** (optional): The id prefix for the resources the source provides - see [Resource Ids](#resource-ids). Everything after the last `=` in the entry is the prefix, so a `url` source whose url contains a `=` must give one.
247
+
248
+ ### Resource Ids
249
+
250
+ Every CodeSystem, ValueSet and ConceptMap is served with an id in the id space of the source it came from: the source's prefix, `-`, and the id the resource has in the source. With
251
+
252
+ ```yaml
253
+ - npm:hl7.terminology=tho
254
+ ```
255
+
256
+ THO's v3-ActCode code system is `CodeSystem/tho-v3-ActCode`. No resource is served on the id it has in its source - different sources can use the same ids (hl7.terminology and the FHIR core packages both have a CodeSystem `v3-MaritalStatus`), so an unprefixed id can't say which one is meant, and it finds nothing.
257
+
258
+ - A prefix may contain letters, digits and `.` (no `-`), up to 32 characters, and each prefix may only be given once
259
+ - If a source isn't given a prefix, it gets a number. Numbers depend on the order of the sources (and on which sources provide which kinds of resource), so they change when the YAML changes. Give every source that provides resources a prefix if its ids should stay the same - i.e. on any server where people link to resources by id
260
+ - `core` is the FHIR core package each endpoint loads for its FHIR version (`CodeSystem/core-administrative-gender`), and `x` is the code systems the server implements natively (`CodeSystem/x-...`); neither can be given as a prefix
261
+ - Sources that only provide code systems natively (`internal`, `snomed`, `loinc` etc.) don't serve resources by id, so a prefix does nothing for them
246
262
 
247
263
  ### Source Types
248
264
 
@@ -464,9 +480,9 @@ sources:
464
480
  - snomed:sct_us_20250901.cache
465
481
 
466
482
  # FHIR packages
467
- - npm:hl7.terminology
468
- - npm:hl7.fhir.us.core
469
- - npm:us.nlm.vsac
483
+ - npm:hl7.terminology=tho
484
+ - npm:hl7.fhir.us.core=us
485
+ - npm:us.nlm.vsac=vsac
470
486
  ```
471
487
 
472
488
  Also see tx.fhir.org.yml for the production configuration for tx.fhir.org
package/tx/cm/cm-api.js CHANGED
@@ -1,22 +1,37 @@
1
+ const { assignIdSpace } = require('../library/resource-ids');
2
+
1
3
  /**
2
4
  * Abstract base class for Concept Map providers
3
5
  * Defines the interface that all Concept Map providers must implement
4
6
  */
5
7
  class AbstractConceptMapProvider {
6
8
  /**
7
- * {int} Unique number assigned to this provider
9
+ * {string} The prefix of this provider's id space (set by assignIds) - given for its source in the library
10
+ * YAML, autonumbered, or 'core' for an endpoint's core package. Every concept map this
11
+ * provider provides has an id that starts with spaceId + '-' (see tx/library/resource-ids.js)
8
12
  */
9
13
  spaceId;
10
14
 
11
15
  /**
12
- * ensure that the ids on the Concept Maps are unique, if they are
13
- * in the global namespace
16
+ * Put this provider in an id space: set spaceId, and prefix the ids of the concept maps it
17
+ * provides with spaceId + '-'. A provider is put in one id space, once - asking for a
18
+ * different one later is an error (see assignIdSpace)
14
19
  *
15
- * @param {Set<String>} ids
20
+ * @param {string} spaceId
16
21
  */
17
- // eslint-disable-next-line no-unused-vars
18
- assignIds(ids) {
19
- throw new Error('assignIds must be implemented by AbstractConceptMapProvider subclass');
22
+ assignIds(spaceId) {
23
+ if (assignIdSpace(this, spaceId)) {
24
+ this.prefixIds();
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Prefix the ids of the concept maps this provider provides with spaceId + '-', once spaceId
30
+ * is set (by assignIds). Concept maps the provider loads later must be given ids
31
+ * in the same space.
32
+ */
33
+ prefixIds() {
34
+ throw new Error('prefixIds must be implemented by AbstractConceptMapProvider subclass');
20
35
  }
21
36
 
22
37
  /**
@@ -32,11 +47,11 @@ class AbstractConceptMapProvider {
32
47
  }
33
48
 
34
49
  /**
35
- * Fetches a specific Concept Map by id. ConceptMap providers must enforce that Concept Map ids are unique
36
- * either globally (as enforced by assignIds) or in their space
50
+ * Fetches a specific Concept Map by id. The id is in this provider's space (spaceId + '-' +
51
+ * the concept map's own id); an id that isn't - including an unprefixed one - finds nothing
37
52
  *
38
53
  * @param {string} id - The id of the Concept Map
39
- * @returns {Promise<ConceptMap>} The requested Concept Map
54
+ * @returns {Promise<ConceptMap>} The requested Concept Map, or null
40
55
  * @throws {Error} Must be implemented by subclasses
41
56
  */
42
57
  // eslint-disable-next-line no-unused-vars
@@ -733,10 +733,6 @@ class ConceptMapDatabase {
733
733
  return { query, params };
734
734
  }
735
735
 
736
- // eslint-disable-next-line no-unused-vars
737
- assignIds(ids) {
738
- // nothing - we don't do any assigning.
739
- }
740
736
  }
741
737
 
742
738
  module.exports = {
@@ -5,6 +5,7 @@ const { ConceptMapDatabase } = require('./cm-database');
5
5
  const { VersionUtilities } = require('../../library/version-utilities');
6
6
  const {validateParameter} = require("../../library/utilities");
7
7
  const {ConceptMap} = require("../library/conceptmap");
8
+ const { applyIdPrefix, stripIdPrefix } = require('../library/resource-ids');
8
9
 
9
10
  /**
10
11
  * Package-based ConceptMap provider using shared database layer
@@ -34,6 +35,7 @@ class PackageConceptMapProvider extends AbstractConceptMapProvider {
34
35
  if (this.initialized) {
35
36
  return;
36
37
  }
38
+ await this.packageLoader.initialize();
37
39
 
38
40
  const dbExists = await this.database.exists();
39
41
 
@@ -43,6 +45,8 @@ class PackageConceptMapProvider extends AbstractConceptMapProvider {
43
45
  }
44
46
 
45
47
  this.conceptMapMap = await this.database.loadAllConceptMaps();
48
+ // nothing, unless the spaceId was assigned before the provider was initialized
49
+ this.#applyIdPrefix();
46
50
  this.initialized = true;
47
51
  }
48
52
 
@@ -128,7 +132,9 @@ class PackageConceptMapProvider extends AbstractConceptMapProvider {
128
132
  this._validateSearchParams(searchParams);
129
133
 
130
134
  if (this.USE_DATABASE_SEARCH) {
131
- return await this.database.search(this.spaceId, searchParams, elements);
135
+ const results = await this.database.search(this.spaceId, searchParams, elements);
136
+ return this.#excludedIds.size === 0 ? results
137
+ : results.filter(r => !this.#excludedIds.has(stripIdPrefix(r.id, this.spaceId)));
132
138
  } else {
133
139
  const matches = [];
134
140
  const seen = new Set(); // Track by URL to avoid duplicates from versioned keys
@@ -194,9 +200,8 @@ class PackageConceptMapProvider extends AbstractConceptMapProvider {
194
200
 
195
201
  if (isMatch) {
196
202
  seen.add(vsUrl);
197
- // Return with prefixed id
198
- const result = { ...json, id: `${this.spaceId}-${json.id}` };
199
- matches.push(result);
203
+ // the id is already prefixed (see assignIds)
204
+ matches.push(json);
200
205
  }
201
206
  }
202
207
 
@@ -298,19 +303,53 @@ class PackageConceptMapProvider extends AbstractConceptMapProvider {
298
303
  }
299
304
 
300
305
  async fetchConceptMapById(id) {
301
- if (!this.spaceId) {
302
- return this.conceptMapMap.get(id);
303
- } else if (id.startsWith(this.spaceId+"-")) {
304
- let key = id.substring(this.spaceId.length + 1);
305
- return this.conceptMapMap.get(key);
306
- } else {
307
- return null;
306
+ // the map is keyed by the concept map's own id (and its url); the concept map itself
307
+ // carries the prefixed id, which is how a url key that happens to match is excluded
308
+ const cm = this.conceptMapMap.get(stripIdPrefix(id, this.spaceId));
309
+ return cm && cm.id === id ? cm : null;
310
+ }
311
+
312
+ sourcePackage() {
313
+ return this.packageLoader.id();
314
+ }
315
+
316
+ // the concept maps left out by excludeUrls, by their own (unprefixed) id
317
+ #excludedIds = new Set();
318
+
319
+ /**
320
+ * Leave out the concept maps with any of these urls: they can't be fetched, they aren't
321
+ * found by search, and they aren't used for translation. The FHIR core package's concept
322
+ * maps are left out where hl7.terminology provides the same url (see
323
+ * Provider.applyTHOPrecedence). Must be called once the provider is initialized - the
324
+ * database is shared, so it isn't changed.
325
+ *
326
+ * @param {Set<string>} urls
327
+ */
328
+ excludeUrls(urls) {
329
+ const excluded = new Set();
330
+ for (const cm of this.conceptMapMap.values()) {
331
+ if (cm && urls.has(cm.url)) {
332
+ excluded.add(cm);
333
+ }
334
+ }
335
+ for (const [key, cm] of [...this.conceptMapMap]) {
336
+ if (excluded.has(cm)) {
337
+ this.conceptMapMap.delete(key);
338
+ }
308
339
  }
340
+ for (const cm of excluded) {
341
+ this.#excludedIds.add(stripIdPrefix(cm.id, this.spaceId) ?? cm.id);
342
+ }
343
+ }
344
+
345
+ prefixIds() {
346
+ this.#applyIdPrefix();
309
347
  }
310
348
 
311
- // eslint-disable-next-line no-unused-vars
312
- assignIds(ids) {
313
- // nothing - we don't do any assigning.
349
+ #applyIdPrefix() {
350
+ for (const cm of new Set(this.conceptMapMap.values())) {
351
+ applyIdPrefix(cm, this.spaceId);
352
+ }
314
353
  }
315
354
 
316
355
  async findConceptMapForTranslation(opContext, conceptMaps, sourceSystem, sourceScope, targetScope, targetSystem) {
@@ -1,22 +1,37 @@
1
+ const { assignIdSpace } = require('../library/resource-ids');
2
+
1
3
  /**
2
4
  * Abstract base class for value set providers
3
5
  * Defines the interface that all value set providers must implement
4
6
  */
5
7
  class AbstractCodeSystemProvider {
6
8
  /**
7
- * {int} Unique number assigned to this provider
9
+ * {string} The prefix of this provider's id space (set by assignIds) - given for its source in the library
10
+ * YAML, or autonumbered. Every code system this provider provides has an id that starts
11
+ * with spaceId + '-' (see tx/library/resource-ids.js)
8
12
  */
9
13
  spaceId;
10
14
 
11
15
  /**
12
- * ensure that the ids on the code systems are unique, if they are
13
- * in the global namespace
16
+ * Put this provider in an id space: set spaceId, and prefix the ids of the code systems it
17
+ * provides with spaceId + '-'. A provider is put in one id space, once - asking for a
18
+ * different one later is an error (see assignIdSpace)
14
19
  *
15
- * @param {Set<String>} ids
20
+ * @param {string} spaceId
16
21
  */
17
- // eslint-disable-next-line no-unused-vars
18
- assignIds(ids) {
19
- throw new Error('assignIds must be implemented by subclass');
22
+ assignIds(spaceId) {
23
+ if (assignIdSpace(this, spaceId)) {
24
+ this.prefixIds();
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Prefix the ids of the code systems this provider provides with spaceId + '-', once spaceId
30
+ * is set (by assignIds). Code systems the provider provides later (see
31
+ * getCodeSystemChanges) must be given ids in the same space.
32
+ */
33
+ prefixIds() {
34
+ throw new Error('prefixIds must be implemented by subclass');
20
35
  }
21
36
 
22
37
  /**
@@ -1,4 +1,5 @@
1
1
  const { AbstractCodeSystemProvider } = require('./cs-provider-api');
2
+ const { applyIdPrefix } = require('../library/resource-ids');
2
3
 
3
4
  /**
4
5
  * Package-based ValueSet provider using shared database layer
@@ -11,18 +12,18 @@ class ListCodeSystemProvider extends AbstractCodeSystemProvider {
11
12
  codeSystems = [];
12
13
 
13
14
  /**
14
- * ensure that the ids on the code systems are unique, if they are
15
- * in the global namespace
16
- *
17
- * @param {Set<String>} ids
15
+ * prefix the ids of the code systems with this provider's spaceId. A code system without
16
+ * an id gets its position in the list
18
17
  */
19
- // eslint-disable-next-line no-unused-vars
20
- assignIds(ids) {
18
+ prefixIds() {
19
+ let i = 0;
21
20
  for (const cs of this.codeSystems) {
22
- if (!cs.id || ids.has("CodeSystem/"+cs.id)) {
23
- cs.id = ""+ids.size;
21
+ i++;
22
+ const json = cs.jsonObj || cs;
23
+ if (!json.id) {
24
+ json.id = String(i);
24
25
  }
25
- ids.add("CodeSystem/"+cs.id);
26
+ applyIdPrefix(cs, this.spaceId);
26
27
  }
27
28
  }
28
29
 
@@ -0,0 +1,107 @@
1
+ //
2
+ // Resource ids in the server's id spaces
3
+ //
4
+ // Every CodeSystem, ValueSet and ConceptMap the server serves by id lives in the id space of
5
+ // the provider that loaded it, and its id is the provider's prefix, a '-', then the id the
6
+ // resource had in its source: "tho-v3-ActCode", "core-v3-MaritalStatus". The prefix is the
7
+ // provider's spaceId: given in the library YAML (npm:hl7.terminology=tho), autonumbered by
8
+ // the library when the YAML doesn't give one, and "core" for the FHIR core package each
9
+ // endpoint loads for itself.
10
+ //
11
+ // No resource is served under its original id. Two sources can carry resources with the same
12
+ // id (hl7.terminology and hl7.fhir.r4.core both have a CodeSystem v3-MaritalStatus), so an
13
+ // unprefixed id can't say which one it means - and anything that looks a resource up by its
14
+ // raw id must fail, rather than find whichever one happens to come first.
15
+ //
16
+
17
+ // resources whose id has been prefixed - so prefixing is idempotent, and a raw id that
18
+ // happens to start with the prefix still gets one
19
+ const prefixed = new WeakSet();
20
+
21
+ /**
22
+ * Prefix a resource's id with spaceId. The resource is either a wrapper with a jsonObj
23
+ * (CodeSystem, ValueSet, ConceptMap) or a plain json object. Does nothing to a resource
24
+ * that has already been prefixed, or that has no id.
25
+ *
26
+ * @param {Object} resource
27
+ * @param {string} spaceId
28
+ */
29
+ function applyIdPrefix(resource, spaceId) {
30
+ if (!resource || !spaceId || prefixed.has(resource)) {
31
+ return;
32
+ }
33
+ const json = resource.jsonObj || resource;
34
+ if (!json.id) {
35
+ return;
36
+ }
37
+ const id = `${spaceId}-${json.id}`;
38
+ json.id = id;
39
+ if (resource.jsonObj && Object.prototype.hasOwnProperty.call(resource, 'id')) {
40
+ resource.id = id;
41
+ }
42
+ prefixed.add(resource);
43
+ }
44
+
45
+ /**
46
+ * The id a resource had in its source, for an id served in spaceId - or null, if the id
47
+ * isn't in that space (including when it's a raw id, or there's no spaceId).
48
+ *
49
+ * @param {string} id
50
+ * @param {string} spaceId
51
+ * @returns {string|null}
52
+ */
53
+ function stripIdPrefix(id, spaceId) {
54
+ if (!spaceId || typeof id !== 'string') {
55
+ return null;
56
+ }
57
+ const prefix = `${spaceId}-`;
58
+ return id.startsWith(prefix) && id.length > prefix.length ? id.substring(prefix.length) : null;
59
+ }
60
+
61
+ /**
62
+ * Whether a prefix given in the library YAML is acceptable. It's the start of an id, so it
63
+ * may only use id characters, and no '-', so that the prefix of any id is unambiguous.
64
+ * 'core' is the endpoints' own core package, and 'x' is the code system factories
65
+ * (CodeSystem/x-...), so neither is available.
66
+ *
67
+ * @param {string} prefix
68
+ * @returns {string|null} why the prefix isn't acceptable, or null if it is
69
+ */
70
+ function checkIdPrefix(prefix) {
71
+ if (!/^[A-Za-z0-9.]{1,32}$/.test(prefix)) {
72
+ return `an id prefix may only contain letters, digits and '.' (up to 32 characters)`;
73
+ }
74
+ if (prefix === CORE_PREFIX || prefix === 'x') {
75
+ return `the id prefix '${prefix}' is reserved`;
76
+ }
77
+ return null;
78
+ }
79
+
80
+ /**
81
+ * Put a provider in an id space - the shared part of the providers' assignIds(spaceId).
82
+ * A provider is put in one id space, once: its resources are prefixed when it is, so
83
+ * moving it to another would leave them in the old one. Asking again for the same space
84
+ * does nothing.
85
+ *
86
+ * @param {Object} provider - a code system, value set or concept map provider
87
+ * @param {string} spaceId
88
+ * @returns {boolean} true if the provider has just been put in the space, and must now
89
+ * prefix its resources' ids
90
+ */
91
+ function assignIdSpace(provider, spaceId) {
92
+ if (!spaceId) {
93
+ throw new Error(`${provider.constructor.name}: no id space given`);
94
+ }
95
+ if (provider.spaceId) {
96
+ if (provider.spaceId === spaceId) {
97
+ return false;
98
+ }
99
+ throw new Error(`${provider.constructor.name} is already in the id space '${provider.spaceId}', so it can't be put in '${spaceId}'`);
100
+ }
101
+ provider.spaceId = spaceId;
102
+ return true;
103
+ }
104
+
105
+ const CORE_PREFIX = 'core';
106
+
107
+ module.exports = { applyIdPrefix, stripIdPrefix, checkIdPrefix, assignIdSpace, CORE_PREFIX };