fhirsmith 0.7.2 → 0.7.4
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 +34 -0
- package/package.json +1 -1
- package/publisher/publisher.js +104 -13
- package/server.js +3 -1
- package/tx/cs/cs-snomed.js +9 -5
- package/tx/cs/cs-uri.js +3 -3
- package/tx/library.js +7 -0
- package/tx/ocl/jobs/background-queue.cjs +11 -5
- package/tx/ocl/shared/patches.cjs +37 -0
- package/tx/ocl/vs-ocl.cjs +78 -39
- package/tx/tx.fhir.org.yml +1 -0
- package/tx/tx.js +94 -18
- package/xig/xig.js +64 -9
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,40 @@ All notable changes to the Health Intersections Node Server will be documented i
|
|
|
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
|
+
## [v0.7.4] - 2026-03-19
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- XIG: show using resource package explicitly
|
|
13
|
+
- TX: Check conformance statement production at start up
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- TX: Load URI provider on tx.fhir.org
|
|
17
|
+
- TX: fix error getting SCT version for html format
|
|
18
|
+
|
|
19
|
+
### Tx Conformance Statement
|
|
20
|
+
|
|
21
|
+
FHIRsmith passed all 1452 HL7 terminology service tests (modes tx.fhir.org+omop+general+snomed, tests v1.9.1-SNAPSHOT, runner v6.9.0)
|
|
22
|
+
|
|
23
|
+
## [v0.7.3] - 2026-03-19
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
|
|
27
|
+
- Show total memory on home page
|
|
28
|
+
- OCL improvements
|
|
29
|
+
- Publisher: Allow editing websites
|
|
30
|
+
- Publisher: separate out target folder and git folder
|
|
31
|
+
- Publisher: use trusted git repo for ig_registry
|
|
32
|
+
- Extend XIG for phinvads analysis
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
- Don't exempt tx/data from npm project
|
|
36
|
+
- SNOMED CT fix: align getLanguageCode with mapLanguageCode byte mapping
|
|
37
|
+
|
|
38
|
+
### Tx Conformance Statement
|
|
39
|
+
|
|
40
|
+
FHIRsmith passed all 1452 HL7 terminology service tests (modes tx.fhir.org+omop+general+snomed, tests v1.9.1-SNAPSHOT, runner v6.9.0)
|
|
41
|
+
|
|
8
42
|
## [v0.7.2] - 2026-03-16
|
|
9
43
|
|
|
10
44
|
### Added
|
package/package.json
CHANGED
package/publisher/publisher.js
CHANGED
|
@@ -198,6 +198,24 @@ class PublisherModule {
|
|
|
198
198
|
});
|
|
199
199
|
});
|
|
200
200
|
}
|
|
201
|
+
const websiteColumns = await new Promise((resolve, reject) => {
|
|
202
|
+
this.db.all("PRAGMA table_info(websites)", (err, rows) => {
|
|
203
|
+
if (err) reject(err);
|
|
204
|
+
else resolve(rows || []);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
const websiteColumnNames = websiteColumns.map(c => c.name);
|
|
208
|
+
if (!websiteColumnNames.includes('git_root')) {
|
|
209
|
+
await new Promise((resolve, reject) => {
|
|
210
|
+
this.db.run('ALTER TABLE websites ADD COLUMN git_root TEXT', (err) => {
|
|
211
|
+
if (err) reject(err);
|
|
212
|
+
else {
|
|
213
|
+
this.logger.info('Migration: added git_root column to websites table');
|
|
214
|
+
resolve();
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
}
|
|
201
219
|
}
|
|
202
220
|
|
|
203
221
|
async createDefaultAdmin() {
|
|
@@ -268,6 +286,8 @@ class PublisherModule {
|
|
|
268
286
|
// Admin routes
|
|
269
287
|
this.router.get('/admin/websites', this.requireAdmin.bind(this), this.renderWebsites.bind(this));
|
|
270
288
|
this.router.post('/admin/websites', this.requireAdmin.bind(this), this.createWebsite.bind(this));
|
|
289
|
+
this.router.get('/admin/websites/:id/edit', this.requireAdmin.bind(this), this.renderEditWebsite.bind(this));
|
|
290
|
+
this.router.post('/admin/websites/:id/edit', this.requireAdmin.bind(this), this.updateWebsite.bind(this));
|
|
271
291
|
this.router.get('/admin/users', this.requireAdmin.bind(this), this.renderUsers.bind(this));
|
|
272
292
|
this.router.post('/admin/users', this.requireAdmin.bind(this), this.createUser.bind(this));
|
|
273
293
|
this.router.post('/admin/permissions', this.requireAdmin.bind(this), this.updatePermissions.bind(this));
|
|
@@ -682,7 +702,7 @@ class PublisherModule {
|
|
|
682
702
|
const historyDir = path.join(taskDir, 'fhir-ig-history-template');
|
|
683
703
|
const templatesDir = path.join(taskDir, 'fhir-web-templates');
|
|
684
704
|
|
|
685
|
-
await this.runCommand('git', ['clone', '
|
|
705
|
+
await this.runCommand('git', ['clone', 'git@github.com:FHIR/ig-registry.git', registryDir],
|
|
686
706
|
{}, task.id, 'Cloning ig-registry');
|
|
687
707
|
|
|
688
708
|
await this.runCommand('git', ['clone', 'https://github.com/HL7/fhir-ig-history-template.git', historyDir],
|
|
@@ -698,7 +718,7 @@ class PublisherModule {
|
|
|
698
718
|
}
|
|
699
719
|
|
|
700
720
|
// Step 3: Pull latest web folder before publishing into it
|
|
701
|
-
await this.runCommand('git', ['pull'], { cwd: website.
|
|
721
|
+
await this.runCommand('git', ['pull'], { cwd: website.git_root }, task.id, 'Pulling latest web folder');
|
|
702
722
|
|
|
703
723
|
// Step 4: Run the IG publisher in go-publish mode
|
|
704
724
|
await this.runPublisherGoPublish(task.id, publisherJar, draftDir, website.local_folder,
|
|
@@ -716,9 +736,9 @@ class PublisherModule {
|
|
|
716
736
|
await this.logTaskMessage(task.id, 'info', 'Committing changes to web folder...');
|
|
717
737
|
const gitUrl = 'https://github.com/' + task.github_org + '/' + task.github_repo + '.git';
|
|
718
738
|
const commitMsg = 'publish ' + task.npm_package_id + '#' + task.version + ' from ' + gitUrl + ' ' + task.git_branch;
|
|
719
|
-
await this.runCommand('git', ['add', '.'], { cwd: website.
|
|
720
|
-
await this.runCommand('git', ['commit', '-m', commitMsg], { cwd: website.
|
|
721
|
-
await this.runCommand('git', ['push'], { cwd: website.
|
|
739
|
+
await this.runCommand('git', ['add', '.'], { cwd: website.git_root }, task.id, 'Staging web folder changes');
|
|
740
|
+
await this.runCommand('git', ['commit', '-m', commitMsg], { cwd: website.git_root }, task.id, 'Committing web folder changes');
|
|
741
|
+
await this.runCommand('git', ['push'], { cwd: website.git_root }, task.id, 'Pushing web folder changes');
|
|
722
742
|
|
|
723
743
|
// Step 7: Commit and push the ig-registry
|
|
724
744
|
await this.logTaskMessage(task.id, 'info', 'Committing changes to ig-registry...');
|
|
@@ -1583,6 +1603,71 @@ class PublisherModule {
|
|
|
1583
1603
|
}
|
|
1584
1604
|
}
|
|
1585
1605
|
|
|
1606
|
+
async renderEditWebsite(req, res) {
|
|
1607
|
+
const start = Date.now();
|
|
1608
|
+
try {
|
|
1609
|
+
const htmlServer = require('../library/html-server');
|
|
1610
|
+
const website = await this.getWebsite(req.params.id);
|
|
1611
|
+
if (!website) return res.status(404).send('Website not found');
|
|
1612
|
+
|
|
1613
|
+
let content = '<h3>Edit Website</h3>';
|
|
1614
|
+
content += '<form method="post" action="/publisher/admin/websites/' + website.id + '/edit" class="row g-3">';
|
|
1615
|
+
content += '<div class="col-md-4"><label class="form-label">Website Name</label>';
|
|
1616
|
+
content += '<input type="text" class="form-control" name="name" value="' + escape(website.name) + '" required></div>';
|
|
1617
|
+
content += '<div class="col-md-4"><label class="form-label">Local Folder</label>';
|
|
1618
|
+
content += '<input type="text" class="form-control" name="local_folder" value="' + escape(website.local_folder) + '" required></div>';
|
|
1619
|
+
content += '<div class="col-md-4"><label class="form-label">Git Root (repo root for git operations)</label>';
|
|
1620
|
+
content += '<input type="text" class="form-control" name="git_root" value="' + escape(website.git_root || '') + '"></div>';
|
|
1621
|
+
content += '<div class="col-md-4"><label class="form-label">History Templates</label>';
|
|
1622
|
+
content += '<input type="text" class="form-control" name="history_templates" value="' + escape(website.history_templates) + '" required></div>';
|
|
1623
|
+
content += '<div class="col-md-4"><label class="form-label">Web Templates</label>';
|
|
1624
|
+
content += '<input type="text" class="form-control" name="web_templates" value="' + escape(website.web_templates) + '" required></div>';
|
|
1625
|
+
content += '<div class="col-md-4"><label class="form-label">Update Script</label>';
|
|
1626
|
+
content += '<input type="text" class="form-control" name="server_update_script" value="' + escape(website.server_update_script) + '" required></div>';
|
|
1627
|
+
content += '<div class="col-md-4"><label class="form-label">Active</label>';
|
|
1628
|
+
content += '<select class="form-control" name="is_active"><option value="1"' + (website.is_active ? ' selected' : '') + '>Yes</option><option value="0"' + (!website.is_active ? ' selected' : '') + '>No</option></select></div>';
|
|
1629
|
+
content += '<div class="col-12"><button type="submit" class="btn btn-primary">Save Changes</button> ';
|
|
1630
|
+
content += '<a href="/publisher/admin/websites" class="btn btn-secondary">Cancel</a></div>';
|
|
1631
|
+
content += '</form>';
|
|
1632
|
+
|
|
1633
|
+
const html = htmlServer.renderPage('publisher', 'Edit Website - FHIR Publisher', content, {
|
|
1634
|
+
templateVars: { loginTitle: 'Logout', loginPath: 'logout', loginAction: 'POST' }
|
|
1635
|
+
});
|
|
1636
|
+
res.setHeader('Content-Type', 'text/html');
|
|
1637
|
+
res.send(html);
|
|
1638
|
+
} catch (error) {
|
|
1639
|
+
this.logger.error('Error rendering edit website:', error);
|
|
1640
|
+
res.status(500).send('Internal server error');
|
|
1641
|
+
} finally {
|
|
1642
|
+
this.stats.countRequest('websites-edit', Date.now() - start);
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
async updateWebsite(req, res) {
|
|
1647
|
+
const start = Date.now();
|
|
1648
|
+
try {
|
|
1649
|
+
const { name, local_folder, git_root, history_templates, web_templates, server_update_script, is_active } = req.body;
|
|
1650
|
+
const websiteId = req.params.id;
|
|
1651
|
+
|
|
1652
|
+
await new Promise((resolve, reject) => {
|
|
1653
|
+
this.db.run(
|
|
1654
|
+
'UPDATE websites SET name=?, local_folder=?, git_root = ?, history_templates=?, web_templates=?, server_update_script=?, is_active=? WHERE id=?',
|
|
1655
|
+
[name, local_folder, git_root, history_templates, web_templates, server_update_script, is_active === '1' ? 1 : 0, websiteId],
|
|
1656
|
+
(err) => err ? reject(err) : resolve()
|
|
1657
|
+
);
|
|
1658
|
+
});
|
|
1659
|
+
|
|
1660
|
+
this.logUserAction(req.session.userId, 'update_website', websiteId, req.ip);
|
|
1661
|
+
this.logger.info('Website updated: ' + websiteId + ' by user ' + req.session.userId);
|
|
1662
|
+
res.redirect('/publisher/admin/websites');
|
|
1663
|
+
} catch (error) {
|
|
1664
|
+
this.logger.error('Error updating website:', error);
|
|
1665
|
+
res.status(500).send('Failed to update website');
|
|
1666
|
+
} finally {
|
|
1667
|
+
this.stats.countRequest('websites-update', Date.now() - start);
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1586
1671
|
async renderWebsites(req, res) {
|
|
1587
1672
|
const start = Date.now();
|
|
1588
1673
|
try {
|
|
@@ -1607,6 +1692,10 @@ class PublisherModule {
|
|
|
1607
1692
|
content += '<input type="text" class="form-control" id="local_folder" name="local_folder" required>';
|
|
1608
1693
|
content += '</div>';
|
|
1609
1694
|
content += '<div class="col-md-4">';
|
|
1695
|
+
content += '<label class="form-label">Git Root (repo root for git operations)</label>';
|
|
1696
|
+
content += '<input type="text" class="form-control" name="git_root" required>';
|
|
1697
|
+
content += '</div>';
|
|
1698
|
+
content += '<div class="col-md-4">';
|
|
1610
1699
|
content += '<label for="history_templates" class="form-label">History Templates</label>';
|
|
1611
1700
|
content += '<input type="text" class="form-control" id="history_templates" name="history_templates" required>';
|
|
1612
1701
|
content += '</div>';
|
|
@@ -1634,18 +1723,20 @@ class PublisherModule {
|
|
|
1634
1723
|
} else {
|
|
1635
1724
|
content += '<div class="table-responsive">';
|
|
1636
1725
|
content += '<table class="table table-striped">';
|
|
1637
|
-
content += '<thead><tr><th>Name</th><th>Local Folder</th><th>Update Script</th><th>Active</th><th>Created</th></tr></thead>';
|
|
1726
|
+
content += '<thead><tr><th>Name</th><th>Local Folder</th><th>Git Root</th><th>Update Script</th><th>Active</th><th>Created</th><th>Actions</th></tr></thead>';
|
|
1638
1727
|
content += '<tbody>';
|
|
1639
1728
|
|
|
1640
1729
|
websites.forEach(website => {
|
|
1641
1730
|
content += '<tr>';
|
|
1642
1731
|
content += '<td>' + website.name + '</td>';
|
|
1643
|
-
content += '<td><code>' + website.local_folder + '</code></td>';
|
|
1644
|
-
content += '<td><code>' + website.
|
|
1645
|
-
content += '<td><code>' + website.
|
|
1646
|
-
content += '<td><code>' + website.
|
|
1732
|
+
content += '<td><code>' + escape(website.local_folder) + '</code></td>';
|
|
1733
|
+
content += '<td><code>' + escape(website.git_root || '') + '</code></td>';
|
|
1734
|
+
content += '<td><code>' + escape(website.history_templates) + '</code></td>';
|
|
1735
|
+
content += '<td><code>' + escape(website.web_templates) + '</code></td>';
|
|
1736
|
+
content += '<td><code>' + escape(website.server_update_script) + '</code></td>';
|
|
1647
1737
|
content += '<td>' + (website.is_active ? '✓' : '✗') + '</td>';
|
|
1648
1738
|
content += '<td>' + new Date(website.created_at).toLocaleString() + '</td>';
|
|
1739
|
+
content += '<td><a href="/publisher/admin/websites/' + website.id + '/edit" class="btn btn-sm btn-outline-primary">Edit</a></td>';
|
|
1649
1740
|
content += '</tr>';
|
|
1650
1741
|
});
|
|
1651
1742
|
|
|
@@ -1677,12 +1768,12 @@ class PublisherModule {
|
|
|
1677
1768
|
const start = Date.now();
|
|
1678
1769
|
try {
|
|
1679
1770
|
try {
|
|
1680
|
-
const {name, local_folder, history_templates, web_templates, server_update_script} = req.body;
|
|
1771
|
+
const {name, local_folder, git_root, history_templates, web_templates, server_update_script} = req.body;
|
|
1681
1772
|
|
|
1682
1773
|
await new Promise((resolve, reject) => {
|
|
1683
1774
|
this.db.run(
|
|
1684
|
-
'INSERT INTO websites (name, local_folder, history_templates, web_templates, server_update_script) VALUES (?, ?, ?, ?, ?)',
|
|
1685
|
-
[name, local_folder, history_templates, web_templates, server_update_script],
|
|
1775
|
+
'INSERT INTO websites (name, local_folder, git_root, history_templates, web_templates, server_update_script) VALUES (?, ?, ?, ?, ?, ?)',
|
|
1776
|
+
[name, local_folder, git_root, history_templates, web_templates, server_update_script],
|
|
1686
1777
|
function (err) {
|
|
1687
1778
|
if (err) reject(err);
|
|
1688
1779
|
else resolve();
|
package/server.js
CHANGED
|
@@ -401,12 +401,14 @@ async function buildRootPageContent() {
|
|
|
401
401
|
const heapUsedMB = (memUsage.heapUsed / 1024 / 1024).toFixed(2);
|
|
402
402
|
const heapTotalMB = (memUsage.heapTotal / 1024 / 1024).toFixed(2);
|
|
403
403
|
const rssMB = (memUsage.rss / 1024 / 1024).toFixed(2);
|
|
404
|
+
const freeMemMB = (os.freemem() / 1024 / 1024).toFixed(0);
|
|
405
|
+
const totalMemMB = (os.totalmem() / 1024 / 1024).toFixed(0);
|
|
404
406
|
|
|
405
407
|
content += '<table class="grid">';
|
|
406
408
|
content += '<tr>';
|
|
407
|
-
content += `<td><strong>Module Count:</strong> ${mc}</td>`;
|
|
408
409
|
content += `<td><strong>Uptime:</strong> ${escape(uptimeStr)}</td>`;
|
|
409
410
|
content += `<td><strong>Request Count:</strong> ${stats.requestCount}</td>`;
|
|
411
|
+
content += `<td><strong>Free Memory:</strong> ${freeMemMB} MB of ${totalMemMB} MB</td>`;
|
|
410
412
|
content += '</tr>';
|
|
411
413
|
content += '<tr>';
|
|
412
414
|
content += `<td><strong>Heap Used:</strong> ${heapUsedMB} MB</td>`;
|
package/tx/cs/cs-snomed.js
CHANGED
|
@@ -615,10 +615,14 @@ class SnomedProvider extends BaseCSServices {
|
|
|
615
615
|
getLanguageCode(langIndex) {
|
|
616
616
|
const languageMap = {
|
|
617
617
|
1: 'en',
|
|
618
|
-
2: '
|
|
619
|
-
3: '
|
|
620
|
-
4: '
|
|
621
|
-
5: '
|
|
618
|
+
2: 'fr',
|
|
619
|
+
3: 'nl',
|
|
620
|
+
4: 'es',
|
|
621
|
+
5: 'sv',
|
|
622
|
+
6: 'da',
|
|
623
|
+
7: 'de',
|
|
624
|
+
8: 'it',
|
|
625
|
+
9: 'cs'
|
|
622
626
|
};
|
|
623
627
|
return languageMap[langIndex] || 'en';
|
|
624
628
|
}
|
|
@@ -1271,7 +1275,7 @@ class SnomedServicesFactory extends CodeSystemFactoryProvider {
|
|
|
1271
1275
|
|
|
1272
1276
|
id() {
|
|
1273
1277
|
const match = this.version().match(/^http:\/\/snomed\.info\/sct\/(\d+)(?:\/version\/(\d{8}))?$/);
|
|
1274
|
-
return "SCT-"+match[1]+"-"+match[2];
|
|
1278
|
+
return match && match[1] && match[2] ? "SCT-"+match[1]+"-"+match[2] : null;
|
|
1275
1279
|
}
|
|
1276
1280
|
|
|
1277
1281
|
describeVersion(version) {
|
package/tx/cs/cs-uri.js
CHANGED
|
@@ -21,7 +21,7 @@ class UriServices extends CodeSystemProvider {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
version() {
|
|
24
|
-
return
|
|
24
|
+
return null;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
description() {
|
|
@@ -182,7 +182,7 @@ class UriServicesFactory extends CodeSystemFactoryProvider {
|
|
|
182
182
|
}
|
|
183
183
|
|
|
184
184
|
defaultVersion() {
|
|
185
|
-
return
|
|
185
|
+
return null;
|
|
186
186
|
}
|
|
187
187
|
|
|
188
188
|
system() {
|
|
@@ -190,7 +190,7 @@ class UriServicesFactory extends CodeSystemFactoryProvider {
|
|
|
190
190
|
}
|
|
191
191
|
|
|
192
192
|
version() {
|
|
193
|
-
return
|
|
193
|
+
return null;
|
|
194
194
|
}
|
|
195
195
|
|
|
196
196
|
// eslint-disable-next-line no-unused-vars
|
package/tx/library.js
CHANGED
|
@@ -34,6 +34,7 @@ const {VSACValueSetProvider} = require("./vs/vs-vsac");
|
|
|
34
34
|
const { OCLCodeSystemProvider, OCLSourceCodeSystemFactory } = require('./ocl/cs-ocl');
|
|
35
35
|
const { OCLValueSetProvider } = require('./ocl/vs-ocl');
|
|
36
36
|
const { OCLConceptMapProvider } = require('./ocl/cm-ocl');
|
|
37
|
+
const {UriServicesFactory} = require("./cs/cs-uri");
|
|
37
38
|
|
|
38
39
|
/**
|
|
39
40
|
* This class holds all the loaded content ready for processing
|
|
@@ -454,6 +455,12 @@ class Library {
|
|
|
454
455
|
this.registerProvider('internal', hgvs);
|
|
455
456
|
break;
|
|
456
457
|
}
|
|
458
|
+
case "urls" : {
|
|
459
|
+
const urls = new UriServicesFactory(this.i18n);
|
|
460
|
+
await urls.load();
|
|
461
|
+
this.registerProvider('internal', urls);
|
|
462
|
+
break;
|
|
463
|
+
}
|
|
457
464
|
case "vsac" : {
|
|
458
465
|
if (!this.vsacCfg || !this.vsacCfg.apiKey) {
|
|
459
466
|
throw new Error("Unable to load VSAC provider unless vsacCfg is provided in the configuration");
|
|
@@ -22,15 +22,17 @@ class OCLBackgroundJobQueue {
|
|
|
22
22
|
const resolveAndEnqueue = async () => {
|
|
23
23
|
const resolvedSize = await this.#resolveJobSize(options);
|
|
24
24
|
const normalizedSize = this.#normalizeJobSize(resolvedSize);
|
|
25
|
-
|
|
25
|
+
const job = {
|
|
26
26
|
jobKey,
|
|
27
27
|
jobType: jobType || 'background-job',
|
|
28
28
|
jobId: options?.jobId || jobKey,
|
|
29
29
|
jobSize: normalizedSize,
|
|
30
30
|
getProgress: typeof options?.getProgress === 'function' ? options.getProgress : null,
|
|
31
31
|
runJob,
|
|
32
|
-
enqueueOrder: this.enqueueSequence
|
|
33
|
-
|
|
32
|
+
enqueueOrder: this.enqueueSequence++,
|
|
33
|
+
userRequested: !!options.userRequested
|
|
34
|
+
};
|
|
35
|
+
this.#insertPendingJobOrdered(job);
|
|
34
36
|
this.ensureHeartbeatRunning();
|
|
35
37
|
console.log(`[OCL] ${jobType || 'Background job'} enqueued: ${jobKey} (size=${normalizedSize}, queue=${this.pendingJobs.length}, active=${this.activeCount})`);
|
|
36
38
|
this.processNext();
|
|
@@ -72,17 +74,21 @@ class OCLBackgroundJobQueue {
|
|
|
72
74
|
}
|
|
73
75
|
|
|
74
76
|
static #insertPendingJobOrdered(job) {
|
|
77
|
+
// Prioridade máxima para userRequested
|
|
78
|
+
if (job.userRequested) {
|
|
79
|
+
this.pendingJobs.unshift(job);
|
|
80
|
+
console.log(`[OCL] User-requested job prioritized: ${job.jobKey}`);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
75
83
|
let index = this.pendingJobs.findIndex(existing => {
|
|
76
84
|
if (existing.jobSize === job.jobSize) {
|
|
77
85
|
return existing.enqueueOrder > job.enqueueOrder;
|
|
78
86
|
}
|
|
79
87
|
return existing.jobSize > job.jobSize;
|
|
80
88
|
});
|
|
81
|
-
|
|
82
89
|
if (index < 0) {
|
|
83
90
|
index = this.pendingJobs.length;
|
|
84
91
|
}
|
|
85
|
-
|
|
86
92
|
this.pendingJobs.splice(index, 0, job);
|
|
87
93
|
}
|
|
88
94
|
|
|
@@ -178,6 +178,43 @@ function patchValueSetExpandWholeSystemForOcl() {
|
|
|
178
178
|
|
|
179
179
|
const originalIncludeCodes = proto.includeCodes;
|
|
180
180
|
proto.includeCodes = async function patchedIncludeCodes(cset, path, vsSrc, compose, filter, expansion, excludeInactive, notClosed) {
|
|
181
|
+
// ...existing code...
|
|
182
|
+
// PATCH: Para OCL ValueSet, só expandir códigos explicitamente listados em compose.include.code
|
|
183
|
+
if (Array.isArray(compose?.include)) {
|
|
184
|
+
const explicitCodes = [];
|
|
185
|
+
for (const include of compose.include) {
|
|
186
|
+
// Normaliza o system para URL canônico
|
|
187
|
+
const canonicalSystem = typeof include.system === 'string' ? include.system.trim() : include.system;
|
|
188
|
+
if (Array.isArray(include.code)) {
|
|
189
|
+
for (const code of include.code) {
|
|
190
|
+
explicitCodes.push({ system: canonicalSystem, code });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
// Também verifica se há conceitos explícitos em include.concept
|
|
194
|
+
if (Array.isArray(include.concept)) {
|
|
195
|
+
for (const concept of include.concept) {
|
|
196
|
+
if (concept && concept.code) {
|
|
197
|
+
explicitCodes.push({ system: canonicalSystem, code: concept.code });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (explicitCodes.length > 0) {
|
|
203
|
+
// Filtra expansão para só retornar os códigos explicitamente referenciados
|
|
204
|
+
const filtered = [];
|
|
205
|
+
for (const { system, code } of explicitCodes) {
|
|
206
|
+
// Busca conceito no CodeSystem
|
|
207
|
+
const resources = await originalIncludeCodes.call(this, { system, code }, path, vsSrc, compose, filter, expansion, excludeInactive, notClosed);
|
|
208
|
+
if (Array.isArray(resources)) {
|
|
209
|
+
filtered.push(...resources);
|
|
210
|
+
} else if (resources) {
|
|
211
|
+
filtered.push(resources);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return filtered;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
// Fallback para comportamento original
|
|
181
218
|
try {
|
|
182
219
|
return await originalIncludeCodes.call(this, cset, path, vsSrc, compose, filter, expansion, excludeInactive, notClosed);
|
|
183
220
|
} catch (error) {
|
package/tx/ocl/vs-ocl.cjs
CHANGED
|
@@ -15,7 +15,7 @@ const { computeValueSetExpansionFingerprint } = require('./fingerprint/fingerpri
|
|
|
15
15
|
const { ensureTxParametersHashIncludesFilter, patchValueSetExpandWholeSystemForOcl } = require('./shared/patches');
|
|
16
16
|
|
|
17
17
|
ensureTxParametersHashIncludesFilter(TxParameters);
|
|
18
|
-
patchValueSetExpandWholeSystemForOcl();
|
|
18
|
+
//patchValueSetExpandWholeSystemForOcl();
|
|
19
19
|
|
|
20
20
|
function normalizeCanonicalSystem(system) {
|
|
21
21
|
if (typeof system !== 'string') {
|
|
@@ -93,10 +93,42 @@ class OCLValueSetProvider extends AbstractValueSetProvider {
|
|
|
93
93
|
dependencyChecksums: cached.dependencyChecksums || {},
|
|
94
94
|
createdAt: Number.isFinite(createdAt) ? createdAt : null
|
|
95
95
|
});
|
|
96
|
-
|
|
96
|
+
// Instancia ValueSet para garantir jsonObj
|
|
97
|
+
// Reconstrói compose.include se não existir
|
|
98
|
+
let compose = cached.expansion?.compose;
|
|
99
|
+
if (!compose || !Array.isArray(compose.include)) {
|
|
100
|
+
// Reconstrói a partir dos sistemas/códigos em expansion.contains
|
|
101
|
+
const systemConcepts = new Map();
|
|
102
|
+
if (Array.isArray(cached.expansion?.contains)) {
|
|
103
|
+
for (const entry of cached.expansion.contains) {
|
|
104
|
+
if (!entry.system || !entry.code) continue;
|
|
105
|
+
if (!systemConcepts.has(entry.system)) {
|
|
106
|
+
systemConcepts.set(entry.system, []);
|
|
107
|
+
}
|
|
108
|
+
systemConcepts.get(entry.system).push({ code: entry.code });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
compose = { include: Array.from(systemConcepts.entries()).map(([system, concepts]) => ({ system, concept: concepts })) };
|
|
112
|
+
}
|
|
113
|
+
const valueSetObj = new ValueSet({
|
|
114
|
+
resourceType: 'ValueSet',
|
|
115
|
+
url: cached.canonicalUrl,
|
|
116
|
+
version: cached.version || null,
|
|
117
|
+
expansion: cached.expansion,
|
|
118
|
+
compose,
|
|
119
|
+
id: cached.canonicalUrl // ou outro identificador se necessário
|
|
120
|
+
}, 'R5');
|
|
121
|
+
this.#applyCachedExpansion(valueSetObj, paramsKey);
|
|
122
|
+
// Indexa o ValueSet restaurado para torná-lo disponível via fetchValueSet
|
|
123
|
+
this.valueSetMap.set(valueSetObj.url, valueSetObj);
|
|
124
|
+
if (valueSetObj.version) {
|
|
125
|
+
this.valueSetMap.set(`${valueSetObj.url}|${valueSetObj.version}`, valueSetObj);
|
|
126
|
+
}
|
|
127
|
+
this.valueSetMap.set(valueSetObj.id, valueSetObj);
|
|
128
|
+
this._idMap.set(valueSetObj.id, valueSetObj);
|
|
97
129
|
this.valueSetFingerprints.set(cacheKey, cached.fingerprint || null);
|
|
98
130
|
loadedCount++;
|
|
99
|
-
console.log(`[OCL-ValueSet] Loaded ValueSet from cold cache: ${cached.canonicalUrl}`);
|
|
131
|
+
console.log(`[OCL-ValueSet] Loaded ValueSet from cold cache into memory: ${cached.canonicalUrl}`);
|
|
100
132
|
} catch (error) {
|
|
101
133
|
console.error(`[OCL-ValueSet] Failed to load cold cache file ${file}:`, error.message);
|
|
102
134
|
}
|
|
@@ -220,9 +252,10 @@ class OCLValueSetProvider extends AbstractValueSetProvider {
|
|
|
220
252
|
let key = `${url}|${version}`;
|
|
221
253
|
if (this.valueSetMap.has(key)) {
|
|
222
254
|
const vs = this.valueSetMap.get(key);
|
|
223
|
-
await this.#ensureComposeIncludes(vs);
|
|
255
|
+
// await this.#ensureComposeIncludes(vs);
|
|
224
256
|
this.#clearInlineExpansion(vs);
|
|
225
|
-
|
|
257
|
+
console.log(`[OCL-ValueSet] fetchValueSet cache hit for ${url} (version: ${version || 'none'})`);
|
|
258
|
+
this.#scheduleBackgroundExpansion(vs, { reason: 'fetch-valueset', userRequested: true });
|
|
226
259
|
return vs;
|
|
227
260
|
}
|
|
228
261
|
|
|
@@ -232,7 +265,7 @@ class OCLValueSetProvider extends AbstractValueSetProvider {
|
|
|
232
265
|
key = `${url}|${majorMinor}`;
|
|
233
266
|
if (this.valueSetMap.has(key)) {
|
|
234
267
|
const vs = this.valueSetMap.get(key);
|
|
235
|
-
await this.#ensureComposeIncludes(vs);
|
|
268
|
+
// await this.#ensureComposeIncludes(vs);
|
|
236
269
|
this.#clearInlineExpansion(vs);
|
|
237
270
|
this.#scheduleBackgroundExpansion(vs, { reason: 'fetch-valueset-mm' });
|
|
238
271
|
return vs;
|
|
@@ -242,7 +275,7 @@ class OCLValueSetProvider extends AbstractValueSetProvider {
|
|
|
242
275
|
|
|
243
276
|
if (this.valueSetMap.has(url)) {
|
|
244
277
|
const vs = this.valueSetMap.get(url);
|
|
245
|
-
await this.#ensureComposeIncludes(vs);
|
|
278
|
+
// await this.#ensureComposeIncludes(vs);
|
|
246
279
|
this.#clearInlineExpansion(vs);
|
|
247
280
|
this.#scheduleBackgroundExpansion(vs, { reason: 'fetch-valueset-url' });
|
|
248
281
|
return vs;
|
|
@@ -250,7 +283,7 @@ class OCLValueSetProvider extends AbstractValueSetProvider {
|
|
|
250
283
|
|
|
251
284
|
const resolved = await this.#resolveValueSetByCanonical(url, version);
|
|
252
285
|
if (resolved) {
|
|
253
|
-
await this.#ensureComposeIncludes(resolved);
|
|
286
|
+
// await this.#ensureComposeIncludes(resolved);
|
|
254
287
|
this.#clearInlineExpansion(resolved);
|
|
255
288
|
this.#scheduleBackgroundExpansion(resolved, { reason: 'fetch-valueset-resolved' });
|
|
256
289
|
return resolved;
|
|
@@ -261,7 +294,7 @@ class OCLValueSetProvider extends AbstractValueSetProvider {
|
|
|
261
294
|
key = `${url}|${version}`;
|
|
262
295
|
if (this.valueSetMap.has(key)) {
|
|
263
296
|
const vs = this.valueSetMap.get(key);
|
|
264
|
-
await this.#ensureComposeIncludes(vs);
|
|
297
|
+
// await this.#ensureComposeIncludes(vs);
|
|
265
298
|
this.#clearInlineExpansion(vs);
|
|
266
299
|
this.#scheduleBackgroundExpansion(vs, { reason: 'fetch-valueset-init' });
|
|
267
300
|
return vs;
|
|
@@ -273,7 +306,7 @@ class OCLValueSetProvider extends AbstractValueSetProvider {
|
|
|
273
306
|
key = `${url}|${majorMinor}`;
|
|
274
307
|
if (this.valueSetMap.has(key)) {
|
|
275
308
|
const vs = this.valueSetMap.get(key);
|
|
276
|
-
await this.#ensureComposeIncludes(vs);
|
|
309
|
+
// await this.#ensureComposeIncludes(vs);
|
|
277
310
|
this.#clearInlineExpansion(vs);
|
|
278
311
|
this.#scheduleBackgroundExpansion(vs, { reason: 'fetch-valueset-init-mm' });
|
|
279
312
|
return vs;
|
|
@@ -283,7 +316,7 @@ class OCLValueSetProvider extends AbstractValueSetProvider {
|
|
|
283
316
|
|
|
284
317
|
if (this.valueSetMap.has(url)) {
|
|
285
318
|
const vs = this.valueSetMap.get(url);
|
|
286
|
-
await this.#ensureComposeIncludes(vs);
|
|
319
|
+
// await this.#ensureComposeIncludes(vs);
|
|
287
320
|
this.#clearInlineExpansion(vs);
|
|
288
321
|
this.#scheduleBackgroundExpansion(vs, { reason: 'fetch-valueset-init-url' });
|
|
289
322
|
return vs;
|
|
@@ -295,7 +328,7 @@ class OCLValueSetProvider extends AbstractValueSetProvider {
|
|
|
295
328
|
async fetchValueSetById(id) {
|
|
296
329
|
const local = this.#getLocalValueSetById(id);
|
|
297
330
|
if (local) {
|
|
298
|
-
await this.#ensureComposeIncludes(local);
|
|
331
|
+
// await this.#ensureComposeIncludes(local);
|
|
299
332
|
this.#clearInlineExpansion(local);
|
|
300
333
|
this.#scheduleBackgroundExpansion(local, { reason: 'fetch-valueset-by-id' });
|
|
301
334
|
return local;
|
|
@@ -304,7 +337,7 @@ class OCLValueSetProvider extends AbstractValueSetProvider {
|
|
|
304
337
|
await this.initialize();
|
|
305
338
|
|
|
306
339
|
const vs = this.#getLocalValueSetById(id);
|
|
307
|
-
await this.#ensureComposeIncludes(vs);
|
|
340
|
+
// await this.#ensureComposeIncludes(vs);
|
|
308
341
|
this.#clearInlineExpansion(vs);
|
|
309
342
|
this.#scheduleBackgroundExpansion(vs, { reason: 'fetch-valueset-by-id-init' });
|
|
310
343
|
return vs;
|
|
@@ -369,17 +402,15 @@ class OCLValueSetProvider extends AbstractValueSetProvider {
|
|
|
369
402
|
|| this._idMap.get(vs.id)
|
|
370
403
|
|| null;
|
|
371
404
|
|
|
372
|
-
//
|
|
373
|
-
if (existing
|
|
374
|
-
this
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
this.
|
|
405
|
+
// Só indexa se não existe ou se for o mesmo objeto
|
|
406
|
+
if (!existing || existing === vs) {
|
|
407
|
+
this.valueSetMap.set(vs.url, vs);
|
|
408
|
+
if (vs.version) {
|
|
409
|
+
this.valueSetMap.set(`${vs.url}|${vs.version}`, vs);
|
|
410
|
+
}
|
|
411
|
+
this.valueSetMap.set(vs.id, vs);
|
|
412
|
+
this._idMap.set(vs.id, vs);
|
|
380
413
|
}
|
|
381
|
-
this.valueSetMap.set(vs.id, vs);
|
|
382
|
-
this._idMap.set(vs.id, vs);
|
|
383
414
|
}
|
|
384
415
|
|
|
385
416
|
#toValueSet(collection) {
|
|
@@ -414,11 +445,11 @@ class OCLValueSetProvider extends AbstractValueSetProvider {
|
|
|
414
445
|
json.meta = { lastUpdated };
|
|
415
446
|
}
|
|
416
447
|
|
|
417
|
-
if (preferredSource) {
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
}
|
|
448
|
+
// if (preferredSource) {
|
|
449
|
+
// json.compose = {
|
|
450
|
+
// include: [{ system: preferredSource }]
|
|
451
|
+
// };
|
|
452
|
+
// }
|
|
422
453
|
|
|
423
454
|
const conceptsUrl = this.#normalizePath(
|
|
424
455
|
collection.concepts_url || collection.conceptsUrl || this.#buildCollectionConceptsPath(collection)
|
|
@@ -995,7 +1026,8 @@ class OCLValueSetProvider extends AbstractValueSetProvider {
|
|
|
995
1026
|
const meta = this.#getCollectionMeta(vs);
|
|
996
1027
|
queuedJobSize = await this.#fetchConceptCountFromHeaders(meta?.conceptsUrl || null);
|
|
997
1028
|
return queuedJobSize;
|
|
998
|
-
}
|
|
1029
|
+
},
|
|
1030
|
+
userRequested: !!options.userRequested
|
|
999
1031
|
}
|
|
1000
1032
|
);
|
|
1001
1033
|
}
|
|
@@ -1080,10 +1112,10 @@ class OCLValueSetProvider extends AbstractValueSetProvider {
|
|
|
1080
1112
|
}
|
|
1081
1113
|
|
|
1082
1114
|
const contains = [];
|
|
1083
|
-
let offset = 0;
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
//
|
|
1115
|
+
let offset = 0; // Moved this line up
|
|
1116
|
+
// Agrupa conceitos por system
|
|
1117
|
+
const systemConcepts = new Map();
|
|
1118
|
+
// Removed duplicate offset declaration
|
|
1087
1119
|
while (true) {
|
|
1088
1120
|
const batch = await this.#fetchCollectionConcepts(meta, {
|
|
1089
1121
|
count: CONCEPT_PAGE_SIZE,
|
|
@@ -1092,17 +1124,14 @@ class OCLValueSetProvider extends AbstractValueSetProvider {
|
|
|
1092
1124
|
filter: null,
|
|
1093
1125
|
languageCodes: []
|
|
1094
1126
|
});
|
|
1095
|
-
|
|
1096
1127
|
const entries = Array.isArray(batch?.contains) ? batch.contains : [];
|
|
1097
1128
|
if (entries.length === 0) {
|
|
1098
1129
|
break;
|
|
1099
1130
|
}
|
|
1100
|
-
|
|
1101
1131
|
for (const entry of entries) {
|
|
1102
1132
|
if (!entry?.system || !entry?.code) {
|
|
1103
1133
|
continue;
|
|
1104
1134
|
}
|
|
1105
|
-
|
|
1106
1135
|
const out = {
|
|
1107
1136
|
system: entry.system,
|
|
1108
1137
|
code: entry.code
|
|
@@ -1125,18 +1154,28 @@ class OCLValueSetProvider extends AbstractValueSetProvider {
|
|
|
1125
1154
|
}));
|
|
1126
1155
|
}
|
|
1127
1156
|
contains.push(out);
|
|
1157
|
+
// Agrupa por system
|
|
1158
|
+
if (!systemConcepts.has(entry.system)) {
|
|
1159
|
+
systemConcepts.set(entry.system, []);
|
|
1160
|
+
}
|
|
1161
|
+
systemConcepts.get(entry.system).push(entry.code);
|
|
1128
1162
|
}
|
|
1129
|
-
|
|
1130
1163
|
if (progressState) {
|
|
1131
1164
|
progressState.processed = contains.length;
|
|
1132
1165
|
}
|
|
1133
|
-
|
|
1134
1166
|
if (entries.length < CONCEPT_PAGE_SIZE) {
|
|
1135
1167
|
break;
|
|
1136
1168
|
}
|
|
1137
1169
|
offset += entries.length;
|
|
1138
1170
|
}
|
|
1139
|
-
|
|
1171
|
+
// Popular compose.include para cada system
|
|
1172
|
+
if (!vs.jsonObj.compose) {
|
|
1173
|
+
vs.jsonObj.compose = { include: [] };
|
|
1174
|
+
}
|
|
1175
|
+
vs.jsonObj.compose.include = Array.from(systemConcepts.entries()).map(([system, codes]) => ({
|
|
1176
|
+
system,
|
|
1177
|
+
concept: codes.map(code => ({ code }))
|
|
1178
|
+
}));
|
|
1140
1179
|
return {
|
|
1141
1180
|
timestamp: new Date().toISOString(),
|
|
1142
1181
|
identifier: `urn:uuid:${crypto.randomUUID()}`,
|
package/tx/tx.fhir.org.yml
CHANGED
package/tx/tx.js
CHANGED
|
@@ -196,6 +196,9 @@ class TXModule {
|
|
|
196
196
|
}
|
|
197
197
|
|
|
198
198
|
this.log.info(`TX module initialized with ${config.endpoints.length} endpoint(s)`);
|
|
199
|
+
|
|
200
|
+
// Self-test: verify metadata generation works for each endpoint before accepting traffic
|
|
201
|
+
await this.selfTest();
|
|
199
202
|
}
|
|
200
203
|
|
|
201
204
|
/**
|
|
@@ -388,8 +391,8 @@ class TXModule {
|
|
|
388
391
|
}
|
|
389
392
|
|
|
390
393
|
if (contentType.includes('application/json') ||
|
|
391
|
-
|
|
392
|
-
|
|
394
|
+
contentType.includes('application/fhir+json') ||
|
|
395
|
+
contentType.includes('application/json+fhir')) {
|
|
393
396
|
|
|
394
397
|
// If body is a Buffer, parse it
|
|
395
398
|
if (Buffer.isBuffer(req.body)) {
|
|
@@ -731,11 +734,11 @@ class TXModule {
|
|
|
731
734
|
router.get('/CodeSystem/:id/\\$validate-code', async (req, res) => {
|
|
732
735
|
const start = Date.now();
|
|
733
736
|
try {
|
|
734
|
-
|
|
737
|
+
let worker = new ValidateWorker(req.txOpContext, this.log, req.txProvider, this.languages, this.i18n);
|
|
735
738
|
await worker.handleCodeSystemInstance(req, res, this.log);
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
+
} finally {
|
|
740
|
+
this.countRequest('$validate', Date.now() - start);
|
|
741
|
+
}
|
|
739
742
|
});
|
|
740
743
|
router.post('/CodeSystem/:id/\\$validate-code', async (req, res) => {
|
|
741
744
|
const start = Date.now();
|
|
@@ -745,7 +748,7 @@ class TXModule {
|
|
|
745
748
|
} finally {
|
|
746
749
|
this.countRequest('$validate', Date.now() - start);
|
|
747
750
|
}
|
|
748
|
-
|
|
751
|
+
|
|
749
752
|
});
|
|
750
753
|
|
|
751
754
|
// ValueSet/[id]/$validate-code
|
|
@@ -964,6 +967,79 @@ class TXModule {
|
|
|
964
967
|
});
|
|
965
968
|
}
|
|
966
969
|
|
|
970
|
+
/**
|
|
971
|
+
* Self-test: exercise CapabilityStatement and TerminologyCapabilities generation
|
|
972
|
+
* for each endpoint immediately after startup, throwing on any failure.
|
|
973
|
+
*/
|
|
974
|
+
async selfTest() {
|
|
975
|
+
this.log.info('Running startup self-test for metadata endpoints...');
|
|
976
|
+
|
|
977
|
+
for (const endpointInfo of this.endpoints) {
|
|
978
|
+
const label = `${endpointInfo.path} (FHIR v${endpointInfo.fhirVersion})`;
|
|
979
|
+
|
|
980
|
+
// Build a minimal mock req/res that captures what metadataHandler.handle() produces
|
|
981
|
+
const makeMockReqRes = (mode) => {
|
|
982
|
+
const captured = { data: null, status: 200 };
|
|
983
|
+
|
|
984
|
+
const req = {
|
|
985
|
+
method: 'GET',
|
|
986
|
+
query: { mode },
|
|
987
|
+
headers: {},
|
|
988
|
+
// eslint-disable-next-line no-unused-vars
|
|
989
|
+
get: (name) => null,
|
|
990
|
+
txEndpoint: endpointInfo,
|
|
991
|
+
txProvider: endpointInfo.provider,
|
|
992
|
+
};
|
|
993
|
+
|
|
994
|
+
const res = {
|
|
995
|
+
statusCode: 200,
|
|
996
|
+
status(code) { captured.status = code; return this; },
|
|
997
|
+
setHeader() { return this; },
|
|
998
|
+
json(data) { captured.data = data; return this; },
|
|
999
|
+
send(data) { captured.data = data; return this; },
|
|
1000
|
+
};
|
|
1001
|
+
|
|
1002
|
+
return { req, res, captured };
|
|
1003
|
+
};
|
|
1004
|
+
|
|
1005
|
+
// Test 1: CapabilityStatement (/metadata with no mode, or mode=full)
|
|
1006
|
+
try {
|
|
1007
|
+
const { req, res, captured } = makeMockReqRes(undefined);
|
|
1008
|
+
await this.metadataHandler.handle(req, res);
|
|
1009
|
+
if (!captured.data) {
|
|
1010
|
+
throw new Error('No response data returned');
|
|
1011
|
+
}
|
|
1012
|
+
const rt = captured.data.resourceType;
|
|
1013
|
+
if (rt !== 'CapabilityStatement') {
|
|
1014
|
+
throw new Error(`Expected CapabilityStatement, got ${rt}`);
|
|
1015
|
+
}
|
|
1016
|
+
this.log.info(` [OK] CapabilityStatement for ${label}`);
|
|
1017
|
+
} catch (err) {
|
|
1018
|
+
this.log.error(` [FAIL] CapabilityStatement for ${label}: ${err.message}`);
|
|
1019
|
+
throw new Error(`Startup self-test failed (CapabilityStatement, ${label}): ${err.message}`);
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
// Test 2: TerminologyCapabilities (/metadata?mode=terminology)
|
|
1023
|
+
try {
|
|
1024
|
+
const { req, res, captured } = makeMockReqRes('terminology');
|
|
1025
|
+
await this.metadataHandler.handle(req, res);
|
|
1026
|
+
if (!captured.data) {
|
|
1027
|
+
throw new Error('No response data returned');
|
|
1028
|
+
}
|
|
1029
|
+
const rt = captured.data.resourceType;
|
|
1030
|
+
if (rt !== 'TerminologyCapabilities') {
|
|
1031
|
+
throw new Error(`Expected TerminologyCapabilities, got ${rt}`);
|
|
1032
|
+
}
|
|
1033
|
+
this.log.info(` [OK] TerminologyCapabilities for ${label}`);
|
|
1034
|
+
} catch (err) {
|
|
1035
|
+
this.log.error(` [FAIL] TerminologyCapabilities for ${label}: ${err.message}`);
|
|
1036
|
+
throw new Error(`Startup self-test failed (TerminologyCapabilities, ${label}): ${err.message}`);
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
this.log.info('Startup self-test passed.');
|
|
1041
|
+
}
|
|
1042
|
+
|
|
967
1043
|
/**
|
|
968
1044
|
* Build an OperationOutcome for errors
|
|
969
1045
|
*/
|
|
@@ -1077,21 +1153,21 @@ class TXModule {
|
|
|
1077
1153
|
ec = 0;
|
|
1078
1154
|
|
|
1079
1155
|
checkProperJson() { // jsonStr) {
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1156
|
+
// const errors = [];
|
|
1157
|
+
// if (jsonStr.includes("[]")) errors.push("Found [] in json");
|
|
1158
|
+
// if (jsonStr.includes('""')) errors.push('Found "" in json');
|
|
1159
|
+
//
|
|
1160
|
+
// if (errors.length > 0) {
|
|
1161
|
+
// this.ec++;
|
|
1162
|
+
// const filename = `/Users/grahamegrieve/temp/tx-err-log/err${this.ec}.json`;
|
|
1163
|
+
// writeFileSync(filename, jsonStr);
|
|
1164
|
+
// throw new Error(errors.join('; '));
|
|
1165
|
+
// }
|
|
1090
1166
|
}
|
|
1091
1167
|
|
|
1092
1168
|
transformResourceForVersion(data, fhirVersion) {
|
|
1093
1169
|
if (fhirVersion == "5.0" || !data.resourceType) {
|
|
1094
|
-
|
|
1170
|
+
return data;
|
|
1095
1171
|
}
|
|
1096
1172
|
switch (data.resourceType) {
|
|
1097
1173
|
case "CodeSystem": return codeSystemFromR5(data, fhirVersion);
|
package/xig/xig.js
CHANGED
|
@@ -99,14 +99,15 @@ function validateExternalUrl(url) {
|
|
|
99
99
|
|
|
100
100
|
// Secure SQL query building with parameterized queries
|
|
101
101
|
function buildSecureResourceQuery(queryParams, offset = 0, limit = 50) {
|
|
102
|
-
const { realm, auth, ver, type, rt, text } = queryParams;
|
|
102
|
+
const { realm, auth, ver, type, rt, text, pkg, onlyUsed } = queryParams;
|
|
103
103
|
|
|
104
104
|
let baseQuery = `
|
|
105
105
|
SELECT
|
|
106
106
|
ResourceKey, ResourceType, Type, Kind, Description, PackageKey,
|
|
107
107
|
Realm, Authority, R2, R2B, R3, R4, R4B, R5, R6,
|
|
108
108
|
Id, Url, Version, Status, Date, Name, Title, Content,
|
|
109
|
-
Supplements, Details, FMM, WG, StandardsStatus, Web
|
|
109
|
+
Supplements, Details, FMM, WG, StandardsStatus, Web,
|
|
110
|
+
(SELECT COUNT(*) FROM DependencyList WHERE TargetKey = Resources.ResourceKey) AS UsageCount
|
|
110
111
|
FROM Resources
|
|
111
112
|
WHERE 1=1
|
|
112
113
|
`;
|
|
@@ -225,6 +226,17 @@ function buildSecureResourceQuery(queryParams, offset = 0, limit = 50) {
|
|
|
225
226
|
}
|
|
226
227
|
}
|
|
227
228
|
|
|
229
|
+
// Package filter - matches packageId#version containing the search string
|
|
230
|
+
if (pkg && pkg !== '') {
|
|
231
|
+
conditions.push('AND PackageKey IN (SELECT PackageKey FROM Packages WHERE (Id || \'#\' || Version) LIKE ?)');
|
|
232
|
+
params.push(`%${pkg}%`);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Only used filter
|
|
236
|
+
if (onlyUsed === 'true') {
|
|
237
|
+
conditions.push('AND EXISTS (SELECT 1 FROM DependencyList WHERE TargetKey = Resources.ResourceKey)');
|
|
238
|
+
}
|
|
239
|
+
|
|
228
240
|
// Build final query
|
|
229
241
|
const fullQuery = baseQuery + ' ' + conditions.join(' ') + ' ORDER BY ResourceType, Type, Description LIMIT ? OFFSET ?';
|
|
230
242
|
params.push(limit, offset);
|
|
@@ -233,7 +245,7 @@ function buildSecureResourceQuery(queryParams, offset = 0, limit = 50) {
|
|
|
233
245
|
}
|
|
234
246
|
|
|
235
247
|
function buildSecureResourceCountQuery(queryParams) {
|
|
236
|
-
const { realm, auth, ver, type, rt, text } = queryParams;
|
|
248
|
+
const { realm, auth, ver, type, rt, text, pkg, onlyUsed } = queryParams;
|
|
237
249
|
|
|
238
250
|
let baseQuery = 'SELECT COUNT(*) as total FROM Resources WHERE 1=1';
|
|
239
251
|
const conditions = [];
|
|
@@ -324,6 +336,17 @@ function buildSecureResourceCountQuery(queryParams) {
|
|
|
324
336
|
}
|
|
325
337
|
}
|
|
326
338
|
|
|
339
|
+
// Package filter - matches packageId#version containing the search string
|
|
340
|
+
if (pkg && pkg !== '') {
|
|
341
|
+
conditions.push('AND PackageKey IN (SELECT PackageKey FROM Packages WHERE (Id || \'#\' || Version) LIKE ?)');
|
|
342
|
+
params.push(`%${pkg}%`);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Only used filter
|
|
346
|
+
if (onlyUsed === 'true') {
|
|
347
|
+
conditions.push('AND EXISTS (SELECT 1 FROM DependencyList WHERE TargetKey = Resources.ResourceKey)');
|
|
348
|
+
}
|
|
349
|
+
|
|
327
350
|
const fullQuery = baseQuery + ' ' + conditions.join(' ');
|
|
328
351
|
return { query: fullQuery, params };
|
|
329
352
|
}
|
|
@@ -449,7 +472,7 @@ function sqlEscapeString(str) {
|
|
|
449
472
|
}
|
|
450
473
|
|
|
451
474
|
function buildSqlFilter(queryParams) {
|
|
452
|
-
const { realm, auth, ver, type, rt, text } = queryParams;
|
|
475
|
+
const { realm, auth, ver, type, rt, text, pkg, onlyUsed } = queryParams;
|
|
453
476
|
let filter = '';
|
|
454
477
|
|
|
455
478
|
// Realm filter
|
|
@@ -555,6 +578,17 @@ function buildSqlFilter(queryParams) {
|
|
|
555
578
|
}
|
|
556
579
|
}
|
|
557
580
|
|
|
581
|
+
// Package filter - matches packageId#version containing the search string
|
|
582
|
+
if (pkg && pkg !== '') {
|
|
583
|
+
const escapedPkg = sqlEscapeString(pkg);
|
|
584
|
+
filter += ` and PackageKey in (select PackageKey from Packages where (Id || '#' || Version) like '%${escapedPkg}%')`;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// Only used filter
|
|
588
|
+
if (onlyUsed === 'true') {
|
|
589
|
+
filter += ` and exists (select 1 from DependencyList where TargetKey = Resources.ResourceKey)`;
|
|
590
|
+
}
|
|
591
|
+
|
|
558
592
|
// Convert to proper WHERE clause
|
|
559
593
|
if (filter !== '') {
|
|
560
594
|
// Remove the first " and " and prepend "WHERE "
|
|
@@ -600,7 +634,8 @@ function buildResourceListQuery(queryParams, offset = 0, limit = 50) {
|
|
|
600
634
|
FMM,
|
|
601
635
|
WG,
|
|
602
636
|
StandardsStatus,
|
|
603
|
-
Web
|
|
637
|
+
Web,
|
|
638
|
+
(SELECT COUNT(*) FROM DependencyList WHERE TargetKey = Resources.ResourceKey) AS UsageCount
|
|
604
639
|
FROM Resources
|
|
605
640
|
${whereClause}
|
|
606
641
|
ORDER BY ResourceType, Type, Description
|
|
@@ -784,6 +819,7 @@ async function buildResourceTable(queryParams, resourceCount, offset = 0) {
|
|
|
784
819
|
break;
|
|
785
820
|
}
|
|
786
821
|
|
|
822
|
+
parts.push('<th>Usage #</th>');
|
|
787
823
|
parts.push('</tr>');
|
|
788
824
|
|
|
789
825
|
const resourceRows = await new Promise((resolve, reject) => {
|
|
@@ -912,6 +948,10 @@ async function buildResourceTable(queryParams, resourceCount, offset = 0) {
|
|
|
912
948
|
}
|
|
913
949
|
}
|
|
914
950
|
|
|
951
|
+
// Usage count column
|
|
952
|
+
const usageCount = row.UsageCount || 0;
|
|
953
|
+
parts.push(`<td>${usageCount > 0 ? usageCount.toLocaleString() : ''}</td>`);
|
|
954
|
+
|
|
915
955
|
parts.push('</tr>');
|
|
916
956
|
}
|
|
917
957
|
|
|
@@ -1116,7 +1156,7 @@ function makeSelect(selectedValue, optionsList, name = 'rt') {
|
|
|
1116
1156
|
}
|
|
1117
1157
|
|
|
1118
1158
|
function buildAdditionalForm(queryParams) {
|
|
1119
|
-
const { ver, realm, auth, type, rt, text } = queryParams;
|
|
1159
|
+
const { ver, realm, auth, type, rt, text, pkg, onlyUsed } = queryParams;
|
|
1120
1160
|
|
|
1121
1161
|
let html = '<form method="GET" action="" style="background-color: #eeeeee; border: 1px black solid; padding: 6px; font-size: 12px; font-family: verdana;">';
|
|
1122
1162
|
|
|
@@ -1142,6 +1182,7 @@ function buildAdditionalForm(queryParams) {
|
|
|
1142
1182
|
const profileResources = getCachedSet('profileResources');
|
|
1143
1183
|
if (profileResources.length > 0) {
|
|
1144
1184
|
html += 'Type: ' + makeSelect(rt, profileResources) + ' ';
|
|
1185
|
+
html += '<br/>';
|
|
1145
1186
|
}
|
|
1146
1187
|
break;
|
|
1147
1188
|
}
|
|
@@ -1150,6 +1191,7 @@ function buildAdditionalForm(queryParams) {
|
|
|
1150
1191
|
const profileTypes = getCachedSet('profileTypes');
|
|
1151
1192
|
if (profileTypes.length > 0) {
|
|
1152
1193
|
html += 'Type: ' + makeSelect(rt, profileTypes) + ' ';
|
|
1194
|
+
html += '<br/>';
|
|
1153
1195
|
}
|
|
1154
1196
|
break;
|
|
1155
1197
|
}
|
|
@@ -1162,6 +1204,7 @@ function buildAdditionalForm(queryParams) {
|
|
|
1162
1204
|
const extensionContexts = getCachedSet('extensionContexts');
|
|
1163
1205
|
if (extensionContexts.length > 0) {
|
|
1164
1206
|
html += 'Context: ' + makeSelect(rt, extensionContexts) + ' ';
|
|
1207
|
+
html += '<br/>';
|
|
1165
1208
|
}
|
|
1166
1209
|
break;
|
|
1167
1210
|
}
|
|
@@ -1172,6 +1215,7 @@ function buildAdditionalForm(queryParams) {
|
|
|
1172
1215
|
// Convert txSources map to "code=display" format
|
|
1173
1216
|
const sourceOptions = Object.keys(txSources).map(code => `${code}=${txSources[code]}`);
|
|
1174
1217
|
html += 'Source: ' + makeSelect(rt, sourceOptions) + ' ';
|
|
1218
|
+
html += '<br/>';
|
|
1175
1219
|
}
|
|
1176
1220
|
break;
|
|
1177
1221
|
}
|
|
@@ -1182,6 +1226,7 @@ function buildAdditionalForm(queryParams) {
|
|
|
1182
1226
|
// Convert txSources map to "code=display" format
|
|
1183
1227
|
const sourceOptionsCM = Object.keys(txSourcesCM).map(code => `${code}=${txSourcesCM[code]}`);
|
|
1184
1228
|
html += 'Source: ' + makeSelect(rt, sourceOptionsCM) + ' ';
|
|
1229
|
+
html += '<br/>';
|
|
1185
1230
|
}
|
|
1186
1231
|
break;
|
|
1187
1232
|
}
|
|
@@ -1190,15 +1235,20 @@ function buildAdditionalForm(queryParams) {
|
|
|
1190
1235
|
const resourceTypes = getCachedSet('resourceTypes');
|
|
1191
1236
|
if (resourceTypes.length > 0) {
|
|
1192
1237
|
html += 'Type: ' + makeSelect(rt, resourceTypes);
|
|
1238
|
+
html += '<br/>';
|
|
1193
1239
|
}
|
|
1194
1240
|
break;
|
|
1195
1241
|
}
|
|
1196
1242
|
}
|
|
1197
1243
|
|
|
1198
|
-
|
|
1244
|
+
|
|
1245
|
+
// Add text search field and package filter field
|
|
1199
1246
|
html += `Text: <input type="text" name="text" value="${escape(text || '')}" class="" style="width: 200px;"/> `;
|
|
1247
|
+
html += `Package: <input type="text" name="pkg" value="${escape(pkg || '')}" placeholder="e.g. hl7.fhir.us" class="" style="width: 200px;"/> `;
|
|
1200
1248
|
|
|
1201
|
-
// Add submit button
|
|
1249
|
+
// Add submit button with 'only used' checkbox immediately before it
|
|
1250
|
+
const onlyUsedChecked = onlyUsed === 'true' ? ' checked' : '';
|
|
1251
|
+
html += `<input type="checkbox" name="onlyUsed" value="true"${onlyUsedChecked}/> Only Show Used `;
|
|
1202
1252
|
html += '<input type="submit" value="Search" style="color:rgb(89, 137, 241)"/>';
|
|
1203
1253
|
|
|
1204
1254
|
html += '</form>';
|
|
@@ -2252,6 +2302,8 @@ router.get('/', async (req, res) => {
|
|
|
2252
2302
|
type: req.query.type || '',
|
|
2253
2303
|
rt: req.query.rt || '',
|
|
2254
2304
|
text: req.query.text || '',
|
|
2305
|
+
pkg: req.query.pkg || '',
|
|
2306
|
+
onlyUsed: req.query.onlyUsed || '',
|
|
2255
2307
|
offset: req.query.offset || '0'
|
|
2256
2308
|
};
|
|
2257
2309
|
|
|
@@ -2696,11 +2748,14 @@ function buildDependencyTable(dependencies) {
|
|
|
2696
2748
|
}
|
|
2697
2749
|
currentType = dep.ResourceType;
|
|
2698
2750
|
html += '<table class="table table-bordered">';
|
|
2699
|
-
html += `<tr style="background-color: #eeeeee"><td colspan="
|
|
2751
|
+
html += `<tr style="background-color: #eeeeee"><td colspan="3"><strong>${escape(currentType)}</strong></td></tr>`;
|
|
2700
2752
|
}
|
|
2701
2753
|
|
|
2702
2754
|
html += '<tr>';
|
|
2703
2755
|
|
|
2756
|
+
// Package column
|
|
2757
|
+
html += `<td>${escape(dep.PID || '')}</td>`;
|
|
2758
|
+
|
|
2704
2759
|
// Build the link to the resource detail page
|
|
2705
2760
|
const packagePid = dep.PID.replace(/#/g, '|'); // Convert # to | for URL
|
|
2706
2761
|
const resourceUrl = `/xig/resource/${encodeURIComponent(packagePid)}/${encodeURIComponent(dep.ResourceType)}/${encodeURIComponent(dep.Id)}`;
|