fhirsmith 0.14.2 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +56 -1
  2. package/config-template.json +10 -0
  3. package/library/changelog.js +57 -0
  4. package/library/npm-audit.js +62 -9
  5. package/package.json +2 -2
  6. package/packages/npm-check-page.js +108 -0
  7. package/packages/npm-check.js +450 -0
  8. package/packages/openapi.yaml +158 -5
  9. package/packages/package-crawler.js +203 -20
  10. package/packages/packages.js +250 -8
  11. package/packages/readme.md +55 -2
  12. package/packages/utilities/dedupe-versions.js +242 -0
  13. package/registry/crawler.js +17 -0
  14. package/registry/fhirsmith-releases.js +3 -6
  15. package/registry/model.js +3 -0
  16. package/registry/registry.js +28 -3
  17. package/translations/Messages.properties +3 -0
  18. package/tx/README.md +20 -4
  19. package/tx/cm/cm-api.js +25 -10
  20. package/tx/cm/cm-database.js +0 -4
  21. package/tx/cm/cm-package.js +53 -14
  22. package/tx/cs/cs-api.js +6 -1
  23. package/tx/cs/cs-icd11.js +23 -6
  24. package/tx/cs/cs-provider-api.js +22 -7
  25. package/tx/cs/cs-provider-list.js +10 -9
  26. package/tx/cs/icd11-expressions.js +5 -6
  27. package/tx/importers/icd11-schema.md +1 -0
  28. package/tx/importers/import-icd11.js +39 -0
  29. package/tx/library/resource-ids.js +107 -0
  30. package/tx/library.js +96 -24
  31. package/tx/ocl/cm-ocl.cjs +23 -15
  32. package/tx/ocl/cs-ocl.cjs +9 -10
  33. package/tx/ocl/vs-ocl.cjs +17 -17
  34. package/tx/provider.js +80 -13
  35. package/tx/tx.fhir.org.yml +17 -9
  36. package/tx/tx.js +7 -1
  37. package/tx/vs/vs-api.js +25 -10
  38. package/tx/vs/vs-database.js +0 -4
  39. package/tx/vs/vs-package.js +48 -31
  40. package/tx/vs/vs-vsac.js +20 -5
  41. package/tx/workers/lookup.js +7 -4
  42. package/tx/workers/metadata.js +2 -1
  43. package/tx/workers/read.js +3 -18
@@ -0,0 +1,242 @@
1
+ #!/usr/bin/env node
2
+ //
3
+ // Copyright 2026, Health Intersections Pty Ltd (http://www.healthintersections.com.au)
4
+ //
5
+ // Licensed under BSD-3: https://opensource.org/license/bsd-3-clause
6
+ //
7
+
8
+ /**
9
+ * One-off repair of a package server's database and mirror, for a database built before
10
+ * the crawler stored each id#version only once.
11
+ *
12
+ * node packages/utilities/dedupe-versions.js -db <packages.db> -mirror <mirror folder> [-apply] [-verbose]
13
+ *
14
+ * Without -apply it reports what it would do and changes nothing. Back up the database
15
+ * first. It is safe to run while the server is up, but best between crawler runs. It can be
16
+ * run with sudo: rewritten mirror files keep the owner, group and mode of the files they replace.
17
+ *
18
+ * 1. Duplicate rows. For each id#version stored more than once, it keeps one row, using the
19
+ * crawler's rule: a direct copy beats a Simplifier copy (a secondary echo); otherwise the
20
+ * first stored wins. Two direct copies with different content are a conflict - only their
21
+ * Simplifier rows are removed, and they are listed for manual cleanup. Deleted rows'
22
+ * download counts are added to the kept row.
23
+ * 2. The unique index on (Id, Version), once there are no duplicates left.
24
+ * 3. The mirror. Every row's mirror file must be the bytes that row's Hash describes; a file
25
+ * that is missing or different is rewritten from the row's Content. Conflicts are skipped.
26
+ * The report separates the two: a different file is served with the wrong checksum; a
27
+ * missing one isn't served at all. -verbose lists every missing file.
28
+ */
29
+
30
+ const fs = require('fs');
31
+ const path = require('path');
32
+ const crypto = require('crypto');
33
+ // node:sqlite (built into Node 24) rather than an npm package, so this file runs on its own:
34
+ // copy it to the server and run it with node, no install needed
35
+ const { DatabaseSync } = require('node:sqlite');
36
+
37
+ const UNIQUE_INDEX = 'CREATE UNIQUE INDEX IF NOT EXISTS SK_PackageVersions_IdVersion ON PackageVersions (Id, Version)';
38
+
39
+ // These two must match PackageCrawler.isSimplifierGuid and PackageCrawler.fixPrefix
40
+ // (tests/packages/package-crawler-versions.test.js checks that they do)
41
+ function isSimplifier(guid) {
42
+ return /^https?:\/\/packages\.simplifier\.net\//i.test(guid || '');
43
+ }
44
+
45
+ function fixPrefix(id) {
46
+ return id && id.startsWith('@') && id.includes('/') ? id.replace('@', '$$').replace('/', '$') : id;
47
+ }
48
+
49
+ const mirrorName = (id, version) => `${fixPrefix(id)}-${version}.tgz`;
50
+
51
+ // When run with sudo, a rewritten file would otherwise belong to root. Give it the owner,
52
+ // group and mode of the file it replaces (or of the mirror folder, for a missing file).
53
+ function keepOwnership(file, like) {
54
+ const st = fs.statSync(like);
55
+ if (process.getuid && process.getuid() === 0) {
56
+ fs.chownSync(file, st.uid, st.gid);
57
+ }
58
+ fs.chmodSync(file, st.isDirectory() ? st.mode & 0o666 : st.mode & 0o7777);
59
+ }
60
+ const sha1 = buf => crypto.createHash('sha1').update(buf).digest('hex');
61
+
62
+ // Which rows of one id#version to keep and delete. rows are in PackageVersionKey order.
63
+ function plan(rows) {
64
+ const direct = rows.filter(r => !isSimplifier(r.GUID));
65
+ if (direct.length === 0) {
66
+ return { keep: [rows[0]], conflict: false };
67
+ }
68
+ if (new Set(direct.map(r => r.Hash)).size === 1) {
69
+ return { keep: [direct[0]], conflict: false };
70
+ }
71
+ return { keep: direct, conflict: true };
72
+ }
73
+
74
+ function dedupe({ dbPath, mirrorPath, apply = false, verbose = false, log = console.log }) {
75
+ if (!fs.existsSync(dbPath)) {
76
+ throw new Error(`No database at ${dbPath}`); // DatabaseSync would create an empty one
77
+ }
78
+ const db = new DatabaseSync(dbPath);
79
+ db.exec('PRAGMA busy_timeout = 10000');
80
+ const report = { groups: 0, deleted: [], conflicts: [], indexCreated: false, mirror: { checked: 0, ok: 0, rewritten: [], badContent: [] } };
81
+
82
+ try {
83
+ // 1. duplicate rows
84
+ const groups = db.prepare('SELECT Id, Version FROM PackageVersions GROUP BY Id, Version HAVING COUNT(*) > 1').all();
85
+ const rowsOf = db.prepare(`SELECT PackageVersionKey, GUID, Hash, DownloadCount FROM PackageVersions
86
+ WHERE Id = ? AND Version = ? ORDER BY PackageVersionKey`);
87
+ const changes = [];
88
+ for (const g of groups) {
89
+ const rows = rowsOf.all(g.Id, g.Version);
90
+ const { keep, conflict } = plan(rows);
91
+ const del = rows.filter(r => !keep.includes(r));
92
+ if (conflict) {
93
+ report.conflicts.push({ id: g.Id, version: g.Version, kept: keep.map(r => r.GUID) });
94
+ }
95
+ if (del.length > 0) {
96
+ changes.push({ id: g.Id, version: g.Version, keep: keep[0], del });
97
+ for (const r of del) {
98
+ report.deleted.push({ id: g.Id, version: g.Version, guid: r.GUID, keptGuid: keep[0].GUID });
99
+ }
100
+ }
101
+ }
102
+ report.groups = groups.length;
103
+ const toDelete = new Set(changes.flatMap(c => c.del.map(r => r.PackageVersionKey)));
104
+ const conflicted = new Set(report.conflicts.map(c => `${c.id}#${c.version}`));
105
+
106
+ if (apply && changes.length > 0) {
107
+ const delRelated = ['PackageFHIRVersions', 'PackageDependencies', 'PackageURLs']
108
+ .map(t => db.prepare(`DELETE FROM ${t} WHERE PackageVersionKey = ?`));
109
+ const delRow = db.prepare('DELETE FROM PackageVersions WHERE PackageVersionKey = ?');
110
+ const addCount = db.prepare('UPDATE PackageVersions SET DownloadCount = DownloadCount + ? WHERE PackageVersionKey = ?');
111
+ const latest = db.prepare(`SELECT PackageVersionKey, Canonical FROM PackageVersions
112
+ WHERE Id = ? AND Version != 'current' ORDER BY PubDate DESC, Version DESC LIMIT 1`);
113
+ const setCurrent = db.prepare('UPDATE Packages SET CurrentVersion = ?, Canonical = ? WHERE Id = ?');
114
+ db.exec('BEGIN IMMEDIATE');
115
+ try {
116
+ const ids = new Set();
117
+ for (const c of changes) {
118
+ for (const r of c.del) {
119
+ delRelated.forEach(s => s.run(r.PackageVersionKey));
120
+ delRow.run(r.PackageVersionKey);
121
+ addCount.run(r.DownloadCount || 0, c.keep.PackageVersionKey);
122
+ }
123
+ ids.add(c.id);
124
+ }
125
+ for (const id of ids) {
126
+ const l = latest.get(id);
127
+ if (l) {
128
+ setCurrent.run(l.PackageVersionKey, l.Canonical, id);
129
+ }
130
+ }
131
+ db.exec('COMMIT');
132
+ } catch (e) {
133
+ db.exec('ROLLBACK');
134
+ throw e;
135
+ }
136
+ }
137
+ log(`${report.groups} id#versions stored more than once; ${report.deleted.length} rows ${apply ? 'deleted' : 'to delete'}; ${report.conflicts.length} conflicts left for manual cleanup`);
138
+ for (const c of report.conflicts) {
139
+ log(` conflict: ${c.id}#${c.version} has different direct copies: ${c.kept.join(', ')}`);
140
+ }
141
+
142
+ // 2. unique index
143
+ const remaining = db.prepare('SELECT COUNT(*) AS n FROM (SELECT 1 FROM PackageVersions GROUP BY Id, Version HAVING COUNT(*) > 1)').get().n;
144
+ if (apply && remaining === 0) {
145
+ db.exec(UNIQUE_INDEX);
146
+ report.indexCreated = true;
147
+ log('unique index on (Id, Version) created');
148
+ } else if (remaining > 0) {
149
+ log(`unique index on (Id, Version) not created: ${apply ? remaining : report.groups} id#versions still duplicated`);
150
+ }
151
+
152
+ // 3. mirror
153
+ if (mirrorPath) {
154
+ const content = db.prepare('SELECT Content FROM PackageVersions WHERE PackageVersionKey = ?');
155
+ for (const row of db.prepare('SELECT PackageVersionKey, Id, Version, Hash FROM PackageVersions ORDER BY Id, Version').iterate()) {
156
+ if (toDelete.has(row.PackageVersionKey)) {
157
+ continue; // a dry run, and -apply would delete this row
158
+ }
159
+ if (conflicted.has(`${row.Id}#${row.Version}`)) {
160
+ continue; // two rows share the file; left for manual cleanup
161
+ }
162
+ report.mirror.checked++;
163
+ const file = path.join(mirrorPath, mirrorName(row.Id, row.Version));
164
+ const actual = fs.existsSync(file) ? sha1(fs.readFileSync(file)) : null;
165
+ if (actual === row.Hash) {
166
+ report.mirror.ok++;
167
+ continue;
168
+ }
169
+ const buf = Buffer.from(content.get(row.PackageVersionKey).Content);
170
+ if (sha1(buf) !== row.Hash) {
171
+ report.mirror.badContent.push(`${row.Id}#${row.Version}`);
172
+ continue;
173
+ }
174
+ report.mirror.rewritten.push({ id: row.Id, version: row.Version, was: actual || 'missing', hash: row.Hash });
175
+ if (apply) {
176
+ const tmp = `${file}.tmp-${process.pid}`;
177
+ fs.writeFileSync(tmp, buf);
178
+ keepOwnership(tmp, fs.existsSync(file) ? file : mirrorPath);
179
+ fs.renameSync(tmp, file);
180
+ }
181
+ }
182
+ const missing = report.mirror.rewritten.filter(r => r.was === 'missing');
183
+ const different = report.mirror.rewritten.filter(r => r.was !== 'missing');
184
+ const will = apply ? 'was written' : 'will be written';
185
+ log(`mirror ${mirrorPath}: ${report.mirror.checked} database rows checked against their mirror file`);
186
+ log(` ${report.mirror.ok} files match the database`);
187
+ log(` ${missing.length} files are missing from the mirror: the package is in the database, so the file ${will} from it`);
188
+ if (missing.length > report.mirror.checked / 2) {
189
+ log(' (most of the mirror is missing: check that -mirror is the folder the server\'s mirrorPath setting names)');
190
+ }
191
+ for (const r of verbose ? missing : missing.slice(0, 5)) {
192
+ log(` ${r.id}#${r.version}`);
193
+ }
194
+ if (!verbose && missing.length > 5) {
195
+ log(` ... and ${missing.length - 5} more (-verbose lists them all)`);
196
+ }
197
+ log(` ${different.length} files differ from the database - these are the ones served with the wrong checksum; each ${will} from the database`);
198
+ for (const r of different) {
199
+ log(` ${r.id}#${r.version}: file sha1 ${r.was}, database ${r.hash}`);
200
+ }
201
+ if (report.mirror.badContent.length > 0) {
202
+ log(` ${report.mirror.badContent.length} database rows whose content doesn't match their own hash - not touched:`);
203
+ for (const b of report.mirror.badContent) {
204
+ log(` ${b}`);
205
+ }
206
+ }
207
+ }
208
+ } finally {
209
+ db.close();
210
+ }
211
+ return report;
212
+ }
213
+
214
+ function main(argv) {
215
+ const arg = name => {
216
+ const i = argv.indexOf(name);
217
+ return i >= 0 ? argv[i + 1] : undefined;
218
+ };
219
+ const dbPath = arg('-db');
220
+ const mirrorPath = arg('-mirror');
221
+ if (!dbPath) {
222
+ console.error('usage: node packages/utilities/dedupe-versions.js -db <packages.db> [-mirror <mirror folder>] [-apply] [-verbose]');
223
+ return 1;
224
+ }
225
+ const apply = argv.includes('-apply');
226
+ if (!apply) {
227
+ console.log('Dry run: nothing will be changed (use -apply)');
228
+ }
229
+ try {
230
+ dedupe({ dbPath, mirrorPath, apply, verbose: argv.includes('-verbose') });
231
+ } catch (e) {
232
+ console.error(e.message);
233
+ return 1;
234
+ }
235
+ return 0;
236
+ }
237
+
238
+ if (require.main === module) {
239
+ process.exitCode = main(process.argv.slice(2));
240
+ }
241
+
242
+ module.exports = { dedupe, plan, isSimplifier, fixPrefix };
@@ -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,35 @@ 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
+ const value = row.softwareReleaseDate || '';
586
+ const m = /^(\d{4}-\d{2}-\d{2})/.exec(value);
587
+ if (!m) {
588
+ return '';
589
+ }
590
+ // a date is the day as given. A dateTime is an instant, and different servers running
591
+ // the same release report it in their own time zones (2026-09-05T00:37:46+10:00 and
592
+ // 2026-09-04T16:37:46+02:00), so the day is the UTC day, to be the same for all of them
593
+ const t = value.length > 10 ? new Date(value).getTime() : NaN;
594
+ return isNaN(t) ? m[1] : new Date(t).toISOString().substring(0, 10);
595
+ }
596
+
597
+ _renderReportedReleaseCells(row, now) {
598
+ const day = this._reportedReleaseDay(row);
599
+ if (!day) {
600
+ return '<td></td><td></td>';
578
601
  }
602
+ const days = Math.max(0, Math.floor((now - new Date(row.softwareReleaseDate).getTime()) / (24 * 60 * 60 * 1000)));
603
+ return `<td>${day}</td><td>${describeAge(days)}</td>`;
579
604
  }
580
605
 
581
606
  /**
@@ -377,6 +377,8 @@ HTA_LOINC_DESC = LOINC
377
377
  HTA_LOINC_MESSAGE = This material contains content from <a href="http://loinc.org">LOINC</a>. LOINC is copyright &copy; 1995-2020, Regenstrief Institute, Inc. and the Logical Observation Identifiers Names and Codes (LOINC) Committee and is available at no cost under the <a href="http://loinc.org/license">license</a>. LOINC&reg; is a registered United States trademark of Regenstrief Institute, Inc.
378
378
  HTA_SCT_DESC = SNOMED Clinical Terms&reg; (SNOMED CT&reg;)
379
379
  HTA_SCT_MESSAGE = This material contains content that is copyright of SNOMED International. Implementers of these specifications must have the appropriate SNOMED CT Affiliate license - for more information contact <a href="https://www.snomed.org/get-snomed">https://www.snomed.org/get-snomed</a> or <a href="mailto:info@snomed.org">info@snomed.org</a>.
380
+ ICD11_PC_NO_AXES = ''{0}'' cannot be postcoordinated onto ''{1}'': that concept declares no postcoordination axes
381
+ ICD11_PC_VALUE_NOT_ON_AXIS = ''{0}'' is not a permitted value on any postcoordination axis of ''{1}''. That stem declares: {2}
380
382
  IG_DEPENDENCY_CAN_VERSION_ALONE = The canonical URL specifies the version ''{0}'', while the dependency does not. If a version is specified, it must be in the explicit version property
381
383
  IG_DEPENDENCY_CAN_VERSION_ERROR = The canonical URL specifies the version ''{0}'', while the dependency has version ''{1}''. If a version is specified, it must be the same as in the explicit version property
382
384
  IG_DEPENDENCY_CAN_VERSION_NONE = The canonical URL cannot end in ''|''
@@ -1441,6 +1443,7 @@ TEXT_LINK_SELECTOR_INVALID = The textLink selector ''{0}'' is invalid: {1}
1441
1443
  SD_CONTEXT_SHOULD_ELEMENT_NOT_FOUND = The element {0} is not valid
1442
1444
  SD_CONTEXT_SHOULD_ELEMENT_NOT_FOUND_VER = The element {0} is not valid in version {1}
1443
1445
  FILTER_NOT_UNDERSTOOD = The filter "{0} {1} {2}" is not understood or supported
1446
+ FILTER_VALUE_UNKNOWN_CODE = The filter value ''{0}'' is not a code in {1}
1444
1447
  XHTML_CONTROL_NO_SOURCE = The xhtml node at ''{0}'' does not have a class attribute to indicate its source (boilerplate, generated, or original) and this is required by the profile {1}
1445
1448
  XHTML_XHTML_MIXED_LANG = The xhtml has some language sections ({0}), and also has content that is not in a language section
1446
1449
  XHTML_CONTROL_NO_LANGS = The xhtml has some language sections ({0}), but language sections are prohibited in this context by the profile {1}
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) {
package/tx/cs/cs-api.js CHANGED
@@ -74,7 +74,12 @@ class CodeSystemProvider {
74
74
  * the others report "LOINC".
75
75
  * @returns {string} display name for the code system
76
76
  */
77
- name() { return this.system() + (this.version() ? "|"+this.version() : "") }
77
+ /**
78
+ * @param {Languages} [languages] - the request's working languages, for a provider whose name
79
+ * is translated; the default ignores them
80
+ */
81
+ // eslint-disable-next-line no-unused-vars
82
+ name(languages) { return this.system() + (this.version() ? "|"+this.version() : "") }
78
83
 
79
84
  /**
80
85
  * @returns {string} uri for the code system