fhirsmith 0.17.0 → 0.18.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 (131) hide show
  1. package/CHANGELOG.md +113 -0
  2. package/README.md +80 -13
  3. package/config-template.json +106 -1
  4. package/ips/ai-client.js +263 -0
  5. package/ips/composer.js +805 -0
  6. package/ips/editor-schema.js +289 -0
  7. package/ips/editor.js +290 -0
  8. package/ips/fhirpath-ips.js +397 -0
  9. package/ips/ips-codeable-concepts.md +1742 -0
  10. package/ips/ips-template.html +125 -0
  11. package/ips/ips-valuesets.js +159 -0
  12. package/ips/ips.js +1736 -0
  13. package/ips/membership.js +179 -0
  14. package/ips/pipeline.js +1012 -0
  15. package/ips/presenter.js +974 -0
  16. package/ips/profile-editor.js +578 -0
  17. package/ips/profiles.js +506 -0
  18. package/ips/readme.md +460 -0
  19. package/ips/render.js +1482 -0
  20. package/ips/search-params.js +83 -0
  21. package/ips/search.js +667 -0
  22. package/ips/share.js +178 -0
  23. package/ips/store.js +650 -0
  24. package/ips/terminology.js +395 -0
  25. package/ips/tx-client.js +202 -0
  26. package/ips/valuesets/CodeSystem-absent-unknown-uv-ips.json +206 -0
  27. package/ips/valuesets/ValueSet-absent-or-unknown-immunizations-uv-ips.json +59 -0
  28. package/ips/valuesets/ValueSet-ips-allergy-code.json +59 -0
  29. package/ips/valuesets/ValueSet-ips-allergy-manifestation.json +356 -0
  30. package/ips/valuesets/ValueSet-ips-allergy-substance.json +37 -0
  31. package/ips/valuesets/ValueSet-ips-body-site.json +26 -0
  32. package/ips/valuesets/ValueSet-ips-clinical-finding-or-none.json +35 -0
  33. package/ips/valuesets/ValueSet-ips-clinical-finding.json +26 -0
  34. package/ips/valuesets/ValueSet-ips-condition-code.json +72 -0
  35. package/ips/valuesets/ValueSet-ips-condition-severity.json +30 -0
  36. package/ips/valuesets/ValueSet-ips-condition-stage.json +26 -0
  37. package/ips/valuesets/ValueSet-ips-exposure-route.json +26 -0
  38. package/ips/valuesets/ValueSet-ips-imaging-procedure.json +25 -0
  39. package/ips/valuesets/ValueSet-ips-immunization-status-reason.json +43 -0
  40. package/ips/valuesets/ValueSet-ips-international-codes.json +25 -0
  41. package/ips/valuesets/ValueSet-ips-medication-ingredient.json +26 -0
  42. package/ips/valuesets/ValueSet-ips-medication-status-reason.json +37 -0
  43. package/ips/valuesets/ValueSet-ips-medication.json +50 -0
  44. package/ips/valuesets/ValueSet-ips-observation-code.json +18 -0
  45. package/ips/valuesets/ValueSet-ips-observation-interpretation.json +18 -0
  46. package/ips/valuesets/ValueSet-ips-procedure-followup.json +51 -0
  47. package/ips/valuesets/ValueSet-ips-procedure-outcome.json +30 -0
  48. package/ips/valuesets/ValueSet-ips-procedure-reason.json +37 -0
  49. package/ips/valuesets/ValueSet-ips-procedure.json +116 -0
  50. package/ips/valuesets/ValueSet-ips-route.json +46 -0
  51. package/ips/valuesets/ValueSet-ips-target-disease.json +171 -0
  52. package/ips/valuesets/ValueSet-ips-vaccine.json +31 -0
  53. package/ips/valuesets/elements.json +44 -0
  54. package/library/daily-report.js +432 -0
  55. package/library/fhir-search-values.js +138 -0
  56. package/library/html-server.js +32 -23
  57. package/library/mailer.js +77 -0
  58. package/library/narrative-check.js +21 -2
  59. package/library/npm-audit.js +25 -0
  60. package/library/package-manager.js +4 -2
  61. package/library/sqlite-session-store.js +29 -1
  62. package/package.json +8 -3
  63. package/packages/packages.js +34 -3
  64. package/publisher/publisher.js +29 -0
  65. package/registry/auth-store.js +366 -0
  66. package/registry/auth.js +872 -0
  67. package/registry/crawler.js +41 -1
  68. package/registry/documentation/api.md +162 -0
  69. package/registry/documentation/configuration.md +93 -0
  70. package/registry/documentation/documents.md +74 -0
  71. package/registry/documentation/internals.md +151 -0
  72. package/registry/documentation/readme.md +100 -0
  73. package/registry/documentation/security.md +199 -0
  74. package/registry/documents.js +309 -0
  75. package/registry/openapi.yaml +30 -0
  76. package/registry/registry-template.html +1 -1
  77. package/registry/registry.js +230 -44
  78. package/registry/uptime.js +154 -0
  79. package/server.js +105 -44
  80. package/shl/shl.js +64 -9
  81. package/stats-db.js +93 -0
  82. package/stats.js +158 -1
  83. package/testing/search.js +2 -108
  84. package/testing/store.js +5 -0
  85. package/testing/testing.js +20 -0
  86. package/token/token-template.html +5 -4
  87. package/token/token.js +17 -10
  88. package/translations/Messages.properties +10 -0
  89. package/tx/README.md +52 -0
  90. package/tx/closure/closure-store.js +14 -0
  91. package/tx/cs/cs-api.js +92 -14
  92. package/tx/cs/cs-cs.js +64 -3
  93. package/tx/cs/cs-icd11.js +366 -112
  94. package/tx/cs/cs-icf.js +431 -0
  95. package/tx/cs/cs-loinc.js +296 -27
  96. package/tx/cs/cs-omop.js +10 -3
  97. package/tx/cs/cs-snomed.js +1210 -80
  98. package/tx/cs/cs-ucum.js +6 -0
  99. package/tx/cs/icd11-expressions.js +191 -78
  100. package/tx/importers/csv-records.js +35 -0
  101. package/tx/importers/export-icd11-codesystem.js +58 -12
  102. package/tx/importers/icd11-schema.md +9 -5
  103. package/tx/importers/import-loinc.module.js +177 -41
  104. package/tx/importers/import-sct.module.js +66 -1
  105. package/tx/importers/subset-loinc.module.js +25 -6
  106. package/tx/library/codesystem.js +4 -1
  107. package/tx/library/designations.js +23 -30
  108. package/tx/library.js +98 -4
  109. package/tx/openapi-operations.js +6 -1
  110. package/tx/params.js +8 -1
  111. package/tx/provider.js +24 -6
  112. package/tx/sct/documentation.md +251 -0
  113. package/tx/sct/ecl.js +758 -46
  114. package/tx/sct/expressions.js +113 -52
  115. package/tx/sct/structures.js +8 -2
  116. package/tx/syndication/syndication-feed.js +218 -0
  117. package/tx/syndication/syndication-store.js +415 -0
  118. package/tx/tx-html.js +15 -1
  119. package/tx/tx.fhir.org.yml +3 -4
  120. package/tx/tx.js +144 -2
  121. package/tx/vs/vs-vsac.js +25 -6
  122. package/tx/workers/closure.js +20 -0
  123. package/tx/workers/expand.js +35 -8
  124. package/tx/workers/lookup.js +61 -6
  125. package/tx/workers/subsumes.js +1 -1
  126. package/tx/workers/translate.js +166 -15
  127. package/tx/workers/validate.js +138 -6
  128. package/utilities/make-sct-loinc-subset.js +250 -0
  129. package/utilities/make-sct-test-extension.js +264 -0
  130. package/xig/xig.js +32 -0
  131. package/registry/readme.md +0 -124
@@ -0,0 +1,415 @@
1
+ /**
2
+ * The syndication store: the content that has flowed through the tx library, as the
3
+ * Terminology Syndication Feed IG (https://build.fhir.org/ig/FHIR/fhir-syndication-ig/)
4
+ * will publish it.
5
+ *
6
+ * The store is a folder (terminology-cache/syndication) holding syndication.json - the
7
+ * metadata for every entry - and files/, copies of the naked resource files the library
8
+ * loaded. Packages and binaries are not copied: packages are permanently available from
9
+ * npm.fhir.org, and the binaries are write-once in the external store they were downloaded
10
+ * from, so their entries link there.
11
+ *
12
+ * Rules (see the syndication design doc in the FHIRsmith project):
13
+ * - content is inducted as the library loads it. Nothing is back-filled: anything the store
14
+ * has not seen before is new, and gets the time it was inducted
15
+ * - the store only grows. Entries are never changed or removed, and there are no retract
16
+ * entries. A later update to the same content (a changed file for the same url|version)
17
+ * is a new entry, so contentItemVersion is deliberately not unique (the IG says it must
18
+ * be; the spec authors agree that rule is wrong)
19
+ * - one store serves every endpoint. Only the core packages are FHIR version specific; their
20
+ * entries carry fhirVersion, and every other entry is in every endpoint's feed
21
+ *
22
+ * Induction is queued and done in the background, one item at a time, so that downloading
23
+ * a package to hash it never holds up the server starting. A failed induction is logged and
24
+ * not recorded, so it is tried again the next time the content is loaded.
25
+ */
26
+
27
+ const fs = require('fs');
28
+ const fsp = fs.promises;
29
+ const path = require('path');
30
+ const crypto = require('crypto');
31
+
32
+ const NCTS_SCHEME = 'http://ns.electronichealth.net.au/ncts/syndication/asf/scheme/1.0.0';
33
+ const FHIRSMITH_SCHEME = 'http://healthintersections.com.au/syndication/fhirsmith/1.0.0';
34
+ const PACKAGE_SERVER = 'https://npm.fhir.org/web/';
35
+ const STORE_FORMAT = 1;
36
+ // no fetch is allowed to hold induction (or server shutdown, which waits for it) for longer
37
+ const FETCH_TIMEOUT_MS = 120000;
38
+
39
+ /** library source type -> category term in the FHIRsmith scheme, for the binary sources */
40
+ const BINARY_TERMS = {
41
+ snomed: 'SNOMED_CACHE',
42
+ loinc: 'LOINC_DB',
43
+ rxnorm: 'RXNORM_DB',
44
+ ndc: 'NDC_DB',
45
+ unii: 'UNII_DB',
46
+ omop: 'OMOP_DB',
47
+ icd11: 'ICD11_DB',
48
+ cpt: 'CPT_DB'
49
+ };
50
+
51
+ const RESOURCE_TYPES = ['CodeSystem', 'ValueSet', 'ConceptMap'];
52
+
53
+ const SNOMED_RIGHTS = 'Use of SNOMED CT is subject to use limitations: a licence from SNOMED International or a national release centre is required';
54
+ const OTHER_RIGHTS = 'Use of this content is subject to the applicable licence';
55
+
56
+ /**
57
+ * Whether a package version is a build rather than a release - #current and its kin are not
58
+ * syndicated, since they change under the same version.
59
+ */
60
+ function isBuildVersion(version) {
61
+ if (!version) {
62
+ return false;
63
+ }
64
+ const v = String(version);
65
+ return /^(current|dev)/.test(v) || v.includes('cibuild');
66
+ }
67
+
68
+ /** SHA-256 of a file, streamed (the binaries run to gigabytes) */
69
+ function hashFile(filePath) {
70
+ return new Promise((resolve, reject) => {
71
+ const hash = crypto.createHash('sha256');
72
+ const stream = fs.createReadStream(filePath);
73
+ stream.on('error', reject);
74
+ stream.on('data', (chunk) => hash.update(chunk));
75
+ stream.on('end', () => resolve(hash.digest('hex')));
76
+ });
77
+ }
78
+
79
+ function hashBuffer(buffer) {
80
+ return crypto.createHash('sha256').update(buffer).digest('hex');
81
+ }
82
+
83
+ /**
84
+ * The SHA-256 and length of a response body, streamed rather than held in memory (some
85
+ * packages are hundreds of megabytes)
86
+ */
87
+ async function hashResponse(response) {
88
+ const hash = crypto.createHash('sha256');
89
+ let length = 0;
90
+ if (response.body && typeof response.body[Symbol.asyncIterator] === 'function') {
91
+ for await (const chunk of response.body) {
92
+ hash.update(chunk);
93
+ length += chunk.length;
94
+ }
95
+ } else {
96
+ const buffer = Buffer.from(await response.arrayBuffer());
97
+ hash.update(buffer);
98
+ length = buffer.length;
99
+ }
100
+ return { sha256: hash.digest('hex'), length };
101
+ }
102
+
103
+ class SyndicationStore {
104
+ /**
105
+ * @param {string} folder - the store folder (created if need be)
106
+ * @param {Object} log - logger
107
+ * @param {Object} [options]
108
+ * @param {Function} [options.fetch] - fetch implementation (tests)
109
+ * @param {Function} [options.now] - clock (tests): returns a Date
110
+ */
111
+ constructor(folder, log, options = {}) {
112
+ this.folder = folder;
113
+ this.filesFolder = path.join(folder, 'files');
114
+ this.jsonPath = path.join(folder, 'syndication.json');
115
+ this.log = log;
116
+ this.fetch = options.fetch || globalThis.fetch;
117
+ this.now = options.now || (() => new Date());
118
+ this.doc = null;
119
+ this.queue = Promise.resolve();
120
+ this.pending = 0;
121
+ }
122
+
123
+ /**
124
+ * Read the store, or start it if there isn't one: the store is defined from now on.
125
+ */
126
+ open() {
127
+ fs.mkdirSync(this.filesFolder, { recursive: true });
128
+ if (fs.existsSync(this.jsonPath)) {
129
+ const doc = JSON.parse(fs.readFileSync(this.jsonPath, 'utf8'));
130
+ if (!doc || !Array.isArray(doc.entries) || !doc.id) {
131
+ throw new Error(`The syndication store ${this.jsonPath} is not valid`);
132
+ }
133
+ this.doc = doc;
134
+ } else {
135
+ this.doc = {
136
+ format: STORE_FORMAT,
137
+ id: crypto.randomUUID(),
138
+ created: this.now().toISOString(),
139
+ entries: []
140
+ };
141
+ this.#save();
142
+ this.log.info(`Syndication store started in ${this.folder}`);
143
+ }
144
+ this.#index();
145
+ return this;
146
+ }
147
+
148
+ #index() {
149
+ // key -> the most recent entry with that key
150
+ this.byKey = new Map();
151
+ // name -> path, for each file copied into the store (the only files it serves)
152
+ this.files = new Map();
153
+ for (const entry of this.doc.entries) {
154
+ this.#indexEntry(entry);
155
+ }
156
+ }
157
+
158
+ #indexEntry(entry) {
159
+ this.byKey.set(entry.key, entry);
160
+ if (entry.link && entry.link.file) {
161
+ this.files.set(entry.link.file, path.join(this.filesFolder, path.basename(entry.link.file)));
162
+ }
163
+ }
164
+
165
+ #save() {
166
+ const tmp = this.jsonPath + '.tmp';
167
+ fs.writeFileSync(tmp, JSON.stringify(this.doc, null, 2));
168
+ fs.renameSync(tmp, this.jsonPath);
169
+ }
170
+
171
+ /** {string} the store's own id, from which each endpoint's feed id is made */
172
+ get id() {
173
+ return this.doc.id;
174
+ }
175
+
176
+ get created() {
177
+ return this.doc.created;
178
+ }
179
+
180
+ /** {Object[]} every entry, in the order they were inducted */
181
+ get entries() {
182
+ return this.doc.entries;
183
+ }
184
+
185
+ /**
186
+ * The entries in the feed for an endpoint at the given FHIR version: everything that isn't
187
+ * version specific, plus that version's own.
188
+ * @param {string} fhirVersion - major.minor
189
+ */
190
+ entriesFor(fhirVersion) {
191
+ return this.doc.entries.filter(e => !e.fhirVersion || e.fhirVersion === fhirVersion);
192
+ }
193
+
194
+ /** Resolves when everything queued so far has been inducted (or has failed) */
195
+ whenIdle() {
196
+ return this.queue;
197
+ }
198
+
199
+ #enqueue(label, task) {
200
+ this.pending++;
201
+ this.queue = this.queue.then(async () => {
202
+ try {
203
+ await task();
204
+ } catch (error) {
205
+ this.log.warn(`Syndication: unable to induct ${label}: ${error.message}`);
206
+ } finally {
207
+ this.pending--;
208
+ }
209
+ });
210
+ return this.queue;
211
+ }
212
+
213
+ #add(entry) {
214
+ const now = this.now().toISOString();
215
+ const full = {
216
+ id: 'urn:uuid:' + crypto.randomUUID(),
217
+ inducted: now,
218
+ ...entry
219
+ };
220
+ this.doc.entries.push(full);
221
+ this.#indexEntry(full);
222
+ this.#save();
223
+ this.log.info(`Syndication: inducted ${full.title} (${full.version})`);
224
+ return full;
225
+ }
226
+
227
+ /** download a url, keeping only its SHA-256 and length */
228
+ async #downloadHash(url) {
229
+ const response = await this.fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
230
+ if (!response.ok) {
231
+ throw new Error(`HTTP ${response.status} fetching ${url}`);
232
+ }
233
+ return hashResponse(response);
234
+ }
235
+
236
+ /**
237
+ * A package the library loaded (npm: or url: source, or an endpoint's core package).
238
+ *
239
+ * @param {Object} pkg
240
+ * @param {string} pkg.id - package id
241
+ * @param {string} pkg.version - package version
242
+ * @param {string} [pkg.canonical] - canonical from package.json
243
+ * @param {string} [pkg.title] - title from package.json
244
+ * @param {string} [pkg.description] - description from package.json
245
+ * @param {string} [pkg.url] - where it came from, for a url: source; otherwise npm.fhir.org
246
+ * @param {string} [pkg.fhirVersion] - major.minor, for a version specific (core) package only
247
+ * @param {string} [pkg.requestedVersion] - the version the library asked for
248
+ */
249
+ inductPackage(pkg) {
250
+ const label = `package ${pkg.id}#${pkg.version}`;
251
+ if (isBuildVersion(pkg.version) || isBuildVersion(pkg.requestedVersion)) {
252
+ return this.queue;
253
+ }
254
+ const key = `package:${pkg.id}#${pkg.version}`;
255
+ if (this.byKey.has(key)) {
256
+ return this.queue;
257
+ }
258
+ return this.#enqueue(label, async () => {
259
+ if (this.byKey.has(key)) {
260
+ return;
261
+ }
262
+ if (!pkg.canonical) {
263
+ throw new Error('the package has no canonical in package.json');
264
+ }
265
+ const href = pkg.url || `${PACKAGE_SERVER}${encodeURIComponent(pkg.id)}-${encodeURIComponent(pkg.version)}.tgz`;
266
+ // packages never change, so the copy on the server is the one that is hashed
267
+ const { sha256, length } = await this.#downloadHash(href);
268
+ const entry = {
269
+ key,
270
+ kind: 'package',
271
+ category: { scheme: NCTS_SCHEME, term: 'FHIR_Package', label: 'FHIR Package' },
272
+ identifier: pkg.canonical,
273
+ version: `${pkg.canonical}|${pkg.version}`,
274
+ title: pkg.title ? `${pkg.title} (${pkg.id}#${pkg.version})` : `${pkg.id}#${pkg.version}`,
275
+ link: { href, type: 'application/gzip', length, sha256 }
276
+ };
277
+ if (pkg.description) {
278
+ entry.summary = pkg.description;
279
+ }
280
+ if (pkg.fhirVersion) {
281
+ entry.fhirVersion = pkg.fhirVersion;
282
+ }
283
+ this.#add(entry);
284
+ });
285
+ }
286
+
287
+ /**
288
+ * A resource the library loaded from a file: or folder: source. Resources without a version
289
+ * are not syndicated. The file is copied into the store, and a later file with different
290
+ * content for the same url|version is a new entry.
291
+ *
292
+ * @param {Object} res
293
+ * @param {string} res.filePath - the file it was loaded from
294
+ * @param {string} res.resourceType
295
+ * @param {string} res.url
296
+ * @param {string} res.version
297
+ * @param {string} [res.title]
298
+ * @param {string} [res.description]
299
+ */
300
+ inductResource(res) {
301
+ if (!res.url || !res.version || !RESOURCE_TYPES.includes(res.resourceType)) {
302
+ return this.queue;
303
+ }
304
+ const label = `${res.resourceType} ${res.url}|${res.version}`;
305
+ const key = `resource:${res.url}|${res.version}`;
306
+ return this.#enqueue(label, async () => {
307
+ const bytes = await fsp.readFile(res.filePath);
308
+ const sha256 = hashBuffer(bytes);
309
+ const last = this.byKey.get(key);
310
+ if (last && last.link.sha256 === sha256) {
311
+ return;
312
+ }
313
+ const fileName = crypto.randomUUID() + '.json';
314
+ await fsp.writeFile(path.join(this.filesFolder, fileName), bytes);
315
+ const entry = {
316
+ key,
317
+ kind: 'resource',
318
+ category: { scheme: NCTS_SCHEME, term: 'FHIR_' + res.resourceType, label: 'FHIR ' + res.resourceType },
319
+ identifier: res.url,
320
+ version: `${res.url}|${res.version}`,
321
+ title: res.title || res.url,
322
+ // relative: the feed makes it absolute under the endpoint the feed is read from
323
+ link: { file: fileName, type: 'application/fhir+json', length: bytes.length, sha256 }
324
+ };
325
+ if (res.description) {
326
+ entry.summary = res.description;
327
+ }
328
+ this.#add(entry);
329
+ });
330
+ }
331
+
332
+ /**
333
+ * A binary the library loaded (SNOMED cache, LOINC database etc). Only binaries that were
334
+ * published to the external store (baseUrl) are syndicated - a file that is only local is a
335
+ * development build. The binary is not copied: the entry links to the external store.
336
+ * Binary file names change whenever their content does, so the file name is the key.
337
+ *
338
+ * @param {Object} bin
339
+ * @param {string} bin.sourceType - library source type (snomed, loinc ...)
340
+ * @param {string} bin.filePath - the local copy
341
+ * @param {string} bin.baseUrl - the external store the library downloads binaries from
342
+ * @param {string} bin.system - code system uri
343
+ * @param {string} bin.version - code system version (for SNOMED, the version uri)
344
+ * @param {string} [bin.name] - code system name
345
+ */
346
+ inductBinary(bin) {
347
+ const term = BINARY_TERMS[bin.sourceType];
348
+ const fileName = path.basename(bin.filePath);
349
+ if (!term || !bin.baseUrl) {
350
+ return this.queue;
351
+ }
352
+ // the full CPT database is licensed content, and is never distributed; the public
353
+ // fragment is
354
+ if (bin.sourceType === 'cpt' && !fileName.toLowerCase().includes('fragment')) {
355
+ return this.queue;
356
+ }
357
+ const key = `binary:${fileName}`;
358
+ if (this.byKey.has(key)) {
359
+ return this.queue;
360
+ }
361
+ return this.#enqueue(`${bin.sourceType} file ${fileName}`, async () => {
362
+ if (this.byKey.has(key)) {
363
+ return;
364
+ }
365
+ const href = bin.baseUrl.replace(/\/+$/, '') + '/' + fileName;
366
+ const stat = await fsp.stat(bin.filePath);
367
+ const head = await this.fetch(href, { method: 'HEAD', signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
368
+ const length = Number(head.headers.get('content-length'));
369
+ if (!head.ok || length !== stat.size) {
370
+ this.log.info(`Syndication: not syndicating ${fileName}: it is not published at ${href} (a local build)`);
371
+ return;
372
+ }
373
+ const sha256 = await hashFile(bin.filePath);
374
+ let identifier = bin.system;
375
+ let version = bin.version ? `${bin.system}|${bin.version}` : bin.system;
376
+ const sct = /^(.*)\/version\/[^/]+$/.exec(bin.version || '');
377
+ if (bin.sourceType === 'snomed' && sct) {
378
+ // SNOMED editions are identified by their module uri, and versioned by the version uri
379
+ identifier = sct[1];
380
+ version = bin.version;
381
+ }
382
+ const name = bin.name || bin.system;
383
+ this.#add({
384
+ key,
385
+ kind: 'binary',
386
+ category: { scheme: FHIRSMITH_SCHEME, term, label: term },
387
+ identifier,
388
+ version,
389
+ title: `${name}${bin.version && !name.includes(bin.version) ? ' ' + bin.version : ''} (${fileName})`,
390
+ summary: `FHIRsmith ${term} file ${fileName}`,
391
+ rights: bin.sourceType === 'snomed' ? SNOMED_RIGHTS : OTHER_RIGHTS,
392
+ link: { href, type: 'application/octet-stream', length: stat.size, sha256 }
393
+ });
394
+ });
395
+ }
396
+
397
+ /**
398
+ * The local path of a file copied into the store, or null if there isn't one by that name.
399
+ * The name is only ever looked up among the files the store's entries record - the path
400
+ * returned is the store's own, never one made from the name asked for.
401
+ * @param {string} name
402
+ */
403
+ filePath(name) {
404
+ if (typeof name !== 'string' || !/^[0-9a-f-]{36}\.json$/.test(name)) {
405
+ return null;
406
+ }
407
+ const p = this.files.get(name);
408
+ return p && fs.existsSync(p) ? p : null;
409
+ }
410
+ }
411
+
412
+ module.exports = {
413
+ SyndicationStore, isBuildVersion, hashFile,
414
+ NCTS_SCHEME, FHIRSMITH_SCHEME, BINARY_TERMS
415
+ };
package/tx/tx-html.js CHANGED
@@ -155,6 +155,14 @@ function publishesLibrarySource() {
155
155
  return publishLibrarySource;
156
156
  }
157
157
 
158
+ // Whether this server has a syndication feed (modules.tx.syndication.enabled): gates the nav
159
+ // item and the feed autodiscovery link, as publishLibrarySource does for the library
160
+ let syndicationFeed = false;
161
+
162
+ function setSyndicationFeed(enabled) {
163
+ syndicationFeed = enabled === true;
164
+ }
165
+
158
166
  /**
159
167
  * Highlight a YAML document for display.
160
168
  *
@@ -269,6 +277,11 @@ class TxHtmlRenderer {
269
277
  options.apiLink = '<a href="' + escape(endpoint.path) + '/openapi" style="color: gold">API</a> &nbsp;|&nbsp;';
270
278
  options.apiHead = '<link rel="service-desc" type="application/json" href="' + escape(endpoint.path) + '/openapi.json"/>';
271
279
  }
280
+ if (syndicationFeed) {
281
+ options.libraryLink += '<a href="' + escape(endpoint.path) + '/syndication" style="color: gold">Syndication</a> &nbsp;|&nbsp;';
282
+ options.apiHead = (options.apiHead || '') + '<link rel="alternate" type="application/atom+xml" title="Syndication Feed" href="'
283
+ + escape(endpoint.path) + '/syndication?_format=atom"/>';
284
+ }
272
285
 
273
286
  return htmlServer.renderPage('tx', title, content, options);
274
287
  }
@@ -1902,5 +1915,6 @@ class TxHtmlRenderer {
1902
1915
 
1903
1916
  module.exports = {
1904
1917
  TxHtmlRenderer, loadTemplate, acceptsHtml,
1905
- setPublishLibrarySource, publishesLibrarySource, highlightYaml, buildLibrarySourcePage
1918
+ setPublishLibrarySource, publishesLibrarySource, highlightYaml, buildLibrarySourcePage,
1919
+ setSyndicationFeed
1906
1920
  };
@@ -20,21 +20,19 @@ sources:
20
20
  - internal:hgvs
21
21
  - ucum:tx/data/ucum-essence.xml
22
22
  - loinc:loinc-2.77-a.db
23
- - loinc!:Loinc_2.82.db
23
+ - loinc!:Loinc_2.83.db
24
24
  - rxnorm:rxnorm_03022026.db
25
25
  - ndc:ndc-20211101.db
26
26
  - unii:unii_20240622.db
27
27
  - snomed:sct_intl_20240201.cache
28
28
  - snomed!:sct_intl_20250201.cache
29
- - snomed:sct_ch_20230607.cache
30
- - snomed:sct_dk_20260331.cache
31
29
  - snomed:sct_ips_20241216.cache
32
30
  - snomed:sct_nl_20240930.cache
33
31
  - snomed:sct_uk_20230412.cache
34
32
  - snomed:sct_us_20230301.cache
35
33
  - snomed:sct_us_20240301.cache
36
34
  - snomed:sct_us_20250901.cache
37
- - snomed:sct_test_20250909b.cache
35
+ - snomed:sct_test_20250909d.cache
38
36
  - cpt:CodeSystem-cpt.db|cpt-2023-fragment-0.1.db
39
37
  - omop:omop_v20250227.db
40
38
  - npm:hl7.terminology=tho
@@ -44,6 +42,7 @@ sources:
44
42
  - npm:hl7.fhir.us.core=us
45
43
  - npm:us.cdc.phinvads=cdc
46
44
  - npm:hl7.fhir.uv.sdc=sdc
45
+ - npm:hl7.fhir.uv.ips=ips
47
46
  - internal:vsac=vsac
48
47
  - icd11:icd11-2026-01-b.db
49
48
 
package/tx/tx.js CHANGED
@@ -59,6 +59,8 @@ const ConceptUsageTracker = require("./usage-tracker");
59
59
  const ProblemFinder = require("./problems");
60
60
  const { buildOperationOutcome, outcomeFromError } = require('./library/operation-outcome');
61
61
  const txOpenApi = require('./openapi');
62
+ const { SyndicationStore } = require('./syndication/syndication-store');
63
+ const syndicationFeed = require('./syndication/syndication-feed');
62
64
  // const {writeFileSync} = require("fs");
63
65
 
64
66
  class TXModule {
@@ -140,9 +142,14 @@ class TXModule {
140
142
  * Initialize the TX module
141
143
  * @param {Object} config - Module configuration
142
144
  * @param {express.Application} app - Express application for registering endpoints
145
+ * @param {Object} [server] - server level settings: {hostName} (config.hostName)
143
146
  */
144
- async initialize(config, app) {
147
+ async initialize(config, app, server = {}) {
145
148
  this.config = config;
149
+ // the server's public host name, when it is configured as one (it is also used as a
150
+ // display name, so anything that isn't a host name is ignored)
151
+ this.publicHost = typeof server.hostName === 'string' && /^[A-Za-z0-9.-]+(:[0-9]{1,5})?$/.test(server.hostName)
152
+ ? server.hostName : null;
146
153
  // Initialize logger with config settings
147
154
  this.log = Logger.getInstance().child({
148
155
  module: 'tx',
@@ -166,6 +173,7 @@ class TXModule {
166
173
  // Off unless the operator opts in. This gates the nav item as well as the route, so a
167
174
  // server that does not publish its library source shows no link to it.
168
175
  txHtml.setPublishLibrarySource(config.publishLibrarySource === true);
176
+ txHtml.setSyndicationFeed(!!(config.syndication && config.syndication.enabled));
169
177
 
170
178
  // Validate config
171
179
  if (!config.librarySource) {
@@ -236,6 +244,17 @@ class TXModule {
236
244
  // Load the library from YAML
237
245
  this.log.info(`Loading library from: ${config.librarySource}`);
238
246
  this.library = new Library(config.librarySource, config.vsacCfg, this.log, this.stats);
247
+
248
+ // The syndication feed: content is recorded in the store as the library loads it, from
249
+ // the time the administrator turns it on (see tx/syndication). One store serves every
250
+ // endpoint, since the library is shared. If it is turned on and the store can't be
251
+ // opened, that stops startup.
252
+ this.syndicationStore = null;
253
+ if (config.syndication && config.syndication.enabled) {
254
+ this.syndicationStore = new SyndicationStore(path.join(this.library.cacheFolder, 'syndication'), this.log).open();
255
+ this.library.syndication = this.syndicationStore;
256
+ this.log.info(`Syndication feed enabled: store in ${this.syndicationStore.folder}`);
257
+ }
239
258
  this.log.info(`Load...`);
240
259
  await this.library.load();
241
260
  this.log.info('Library loaded successfully');
@@ -1133,6 +1152,10 @@ class TXModule {
1133
1152
  });
1134
1153
  }
1135
1154
 
1155
+ if (this.syndicationStore) {
1156
+ this.setupSyndicationRoutes(router, endpointPath);
1157
+ }
1158
+
1136
1159
  // Metadata / CapabilityStatement
1137
1160
  router.get('/metadata', async (req, res) => {
1138
1161
  const start = Date.now();
@@ -1332,6 +1355,31 @@ class TXModule {
1332
1355
  };
1333
1356
  }
1334
1357
 
1358
+ /**
1359
+ * The paragraph for the server's daily report: how many of each operation
1360
+ * were performed, over all the endpoints.
1361
+ */
1362
+ dailyReport({since, now}) {
1363
+ const {plural} = require('../library/daily-report');
1364
+ if (!this.stats) {
1365
+ return null;
1366
+ }
1367
+ const counts = this.stats.countsSince(since, now);
1368
+ if (counts.total === 0) {
1369
+ return 'No requests were served.';
1370
+ }
1371
+ // the FHIR operations first, busiest first, then the rest (pages, metadata, reads)
1372
+ const names = Object.keys(counts.operations).sort((a, b) => {
1373
+ const opA = a.startsWith('$') ? 0 : 1;
1374
+ const opB = b.startsWith('$') ? 0 : 1;
1375
+ return opA - opB || counts.operations[b] - counts.operations[a] || a.localeCompare(b);
1376
+ });
1377
+ const list = names.map(n => `${n} ${counts.operations[n].toLocaleString('en-US')}`).join(', ');
1378
+ const endpoints = Object.keys(counts.endpoints).filter(e => e).length;
1379
+ const where = endpoints > 1 ? ` over ${plural(endpoints, 'endpoint')}` : '';
1380
+ return `${counts.total.toLocaleString('en-US')} requests were served${where}: ${list}.`;
1381
+ }
1382
+
1335
1383
  /**
1336
1384
  * Shutdown the module
1337
1385
  */
@@ -1341,6 +1389,10 @@ class TXModule {
1341
1389
  clearInterval(timer);
1342
1390
  }
1343
1391
  this.timers = [];
1392
+ if (this.syndicationStore) {
1393
+ // let an induction in progress finish, so the store isn't left with a half-copied file
1394
+ await this.syndicationStore.whenIdle();
1395
+ }
1344
1396
  // Clean up any resources if needed
1345
1397
  await this.library.close();
1346
1398
  if (this.closureStore) {
@@ -1350,6 +1402,93 @@ class TXModule {
1350
1402
  this.log.info('TX module shut down');
1351
1403
  }
1352
1404
 
1405
+ /**
1406
+ * The syndication feed for an endpoint (Terminology Syndication Feed IG, RFC 5005
1407
+ * archived feeds) - see tx/syndication/syndication-feed.js. Atom unless HTML is asked for.
1408
+ */
1409
+ /**
1410
+ * The public url of an endpoint, for the links in its feed: from the configured host name
1411
+ * (config.hostName), so the links don't depend on what a client puts in the Host header.
1412
+ * Only without one does it fall back to the request (as for a local test server).
1413
+ */
1414
+ syndicationBase(req, endpointPath) {
1415
+ if (this.publicHost) {
1416
+ return `https://${this.publicHost}${endpointPath}`;
1417
+ }
1418
+ return `${req.protocol}://${req.get('host')}${endpointPath}`;
1419
+ }
1420
+
1421
+ setupSyndicationRoutes(router, endpointPath) {
1422
+ const sendFeed = async (req, res, page) => {
1423
+ const start = Date.now();
1424
+ try {
1425
+ const params = {
1426
+ store: this.syndicationStore,
1427
+ endpointPath,
1428
+ fhirVersion: req.txEndpoint.fhirVersion,
1429
+ base: this.syndicationBase(req, endpointPath),
1430
+ page,
1431
+ title: this.config.title,
1432
+ serverVersion: packageJson.version
1433
+ };
1434
+ if (txHtml.acceptsHtml(req)) {
1435
+ const content = syndicationFeed.renderHtml(params);
1436
+ if (content == null) {
1437
+ res.status(404).json(this.operationOutcome('error', 'not-found', 'There is no such syndication archive'));
1438
+ return;
1439
+ }
1440
+ let txhtml = new TxHtmlRenderer(new Renderer(req.txOpContext, req.txProvider), this.liquid, this.languages, this.i18n, endpointPath);
1441
+ const title = page == null ? 'Syndication Feed' : `Syndication Feed (archive ${page})`;
1442
+ const html = await txhtml.renderPage(title, content, req.txEndpoint, req.txStartTime);
1443
+ res.setHeader('Content-Type', 'text/html');
1444
+ res.send(html);
1445
+ return;
1446
+ }
1447
+ const xml = syndicationFeed.renderAtom(params);
1448
+ if (xml == null) {
1449
+ res.status(404).json(this.operationOutcome('error', 'not-found', 'There is no such syndication archive'));
1450
+ return;
1451
+ }
1452
+ // an archive never changes; the current document changes whenever content is added.
1453
+ // An archive is only cacheable forever when its links come from the configured host
1454
+ // name - links made from the request's Host header must not be cached for others
1455
+ res.setHeader('Cache-Control', page == null || !this.publicHost ? 'no-cache' : 'public, max-age=31536000, immutable');
1456
+ res.setHeader('Content-Type', 'application/atom+xml; charset=utf-8');
1457
+ res.setHeader('X-Content-Type-Options', 'nosniff');
1458
+ res.send(xml);
1459
+ } catch (error) {
1460
+ this.log.error(`Error rendering syndication feed: ${error.message}`);
1461
+ res.status(500).json(this.operationOutcome('error', 'exception', 'Unable to render the syndication feed'));
1462
+ } finally {
1463
+ this.countRequest(endpointPath, 'syndication', Date.now() - start);
1464
+ }
1465
+ };
1466
+
1467
+ router.get('/syndication', (req, res) => sendFeed(req, res, null));
1468
+
1469
+ router.get('/syndication/archive/:page', (req, res) => {
1470
+ const page = /^[1-9][0-9]{0,8}$/.test(req.params.page) ? Number(req.params.page) : -1;
1471
+ return sendFeed(req, res, page);
1472
+ });
1473
+
1474
+ // not rate limited here: as for the rest of the tx module, nginx does that (see nginx.md)
1475
+ router.get('/syndication/files/:name', (req, res) => {
1476
+ const file = this.syndicationStore.filePath(req.params.name);
1477
+ if (!file) {
1478
+ res.status(404).json(this.operationOutcome('error', 'not-found', 'There is no such syndication file'));
1479
+ return;
1480
+ }
1481
+ // copies in the store never change
1482
+ res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
1483
+ res.setHeader('Content-Type', 'application/fhir+json');
1484
+ // the file is whatever the source folder held: never let a browser sniff it as a page
1485
+ res.setHeader('X-Content-Type-Options', 'nosniff');
1486
+ // served from the store's folder by the store's own name for it (sendFile refuses
1487
+ // anything that would leave root)
1488
+ res.sendFile(path.basename(file), { root: this.syndicationStore.filesFolder });
1489
+ });
1490
+ }
1491
+
1353
1492
  trimParameters(params) {
1354
1493
  if (!params || !params.parameter) {
1355
1494
  return params;
@@ -1515,8 +1654,11 @@ class TXModule {
1515
1654
  if (hdr) {
1516
1655
  return parseInt(hdr);
1517
1656
  }
1657
+ // the tx-ecosystem test runner (Tools/Java) always gets the test limit, so every server
1658
+ // gives it the same answer. Otherwise the configured externalLimit - it used to be read
1659
+ // only when internalLimit was configured too, so setting it alone did nothing
1518
1660
  let isTest = req.header("User-Agent") == 'Tools/Java';
1519
- if (this.config.internalLimit && !isTest) return this.config.externalLimit; else return EXTERNAL_TEST_DEFAULT_LIMIT;
1661
+ if (this.config.externalLimit && !isTest) return this.config.externalLimit; else return EXTERNAL_TEST_DEFAULT_LIMIT;
1520
1662
  }
1521
1663
 
1522
1664
  }