fhirsmith 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +2 -0
  3. package/configurations/projector.json +21 -0
  4. package/configurations/readme.md +5 -0
  5. package/library/package-manager.js +0 -2
  6. package/library/version-utilities.js +85 -0
  7. package/package.json +1 -1
  8. package/packages/package-crawler.js +44 -9
  9. package/packages/packages.js +1 -0
  10. package/registry/crawler.js +35 -14
  11. package/registry/registry.js +3 -0
  12. package/server.js +4 -0
  13. package/tx/README.md +4 -4
  14. package/tx/cs/cs-loinc.js +5 -2
  15. package/tx/cs/cs-provider-api.js +25 -1
  16. package/tx/cs/cs-provider-list.js +2 -2
  17. package/tx/library/canonical-resource.js +6 -1
  18. package/tx/library.js +127 -10
  19. package/tx/ocl/README.md +236 -0
  20. package/tx/ocl/cache/cache-paths.cjs +32 -0
  21. package/tx/ocl/cache/cache-paths.js +2 -0
  22. package/tx/ocl/cache/cache-utils.cjs +43 -0
  23. package/tx/ocl/cache/cache-utils.js +2 -0
  24. package/tx/ocl/cm-ocl.cjs +531 -0
  25. package/tx/ocl/cm-ocl.js +1 -105
  26. package/tx/ocl/cs-ocl.cjs +1779 -0
  27. package/tx/ocl/cs-ocl.js +1 -38
  28. package/tx/ocl/fingerprint/fingerprint.cjs +67 -0
  29. package/tx/ocl/fingerprint/fingerprint.js +2 -0
  30. package/tx/ocl/http/client.cjs +31 -0
  31. package/tx/ocl/http/client.js +2 -0
  32. package/tx/ocl/http/pagination.cjs +98 -0
  33. package/tx/ocl/http/pagination.js +2 -0
  34. package/tx/ocl/jobs/background-queue.cjs +200 -0
  35. package/tx/ocl/jobs/background-queue.js +2 -0
  36. package/tx/ocl/mappers/concept-mapper.cjs +66 -0
  37. package/tx/ocl/mappers/concept-mapper.js +2 -0
  38. package/tx/ocl/model/concept-filter-context.cjs +51 -0
  39. package/tx/ocl/model/concept-filter-context.js +2 -0
  40. package/tx/ocl/shared/constants.cjs +15 -0
  41. package/tx/ocl/shared/constants.js +2 -0
  42. package/tx/ocl/shared/patches.cjs +224 -0
  43. package/tx/ocl/shared/patches.js +2 -0
  44. package/tx/ocl/vs-ocl.cjs +1848 -0
  45. package/tx/ocl/vs-ocl.js +1 -104
  46. package/tx/operation-context.js +8 -1
  47. package/tx/params.js +24 -3
  48. package/tx/provider.js +47 -0
  49. package/tx/tx-html.js +1 -1
  50. package/tx/tx.js +8 -0
  51. package/tx/vs/vs-vsac.js +4 -3
  52. package/tx/workers/batch-validate.js +3 -2
  53. package/tx/workers/batch.js +3 -2
  54. package/tx/workers/expand.js +64 -9
  55. package/tx/workers/lookup.js +5 -4
  56. package/tx/workers/read.js +2 -1
  57. package/tx/workers/related.js +3 -2
  58. package/tx/workers/search.js +4 -9
  59. package/tx/workers/subsumes.js +3 -2
  60. package/tx/workers/translate.js +4 -3
  61. package/tx/workers/validate.js +132 -40
  62. package/tx/workers/worker.js +1 -7
package/tx/ocl/cs-ocl.js CHANGED
@@ -1,39 +1,2 @@
1
- /**
2
- * Abstract base class for value set providers
3
- * Defines the interface that all value set providers must implement
4
- */
5
- // eslint-disable-next-line no-unused-vars
6
- class OCLCodeSystemProvider {
7
- /**
8
- * {int} Unique number assigned to this provider
9
- */
10
- spaceId;
1
+ module.exports = require('./cs-ocl.cjs');
11
2
 
12
- /**
13
- * ensure that the ids on the code systems are unique, if they are
14
- * in the global namespace
15
- *
16
- * @param {Set<String>} ids
17
- */
18
- // eslint-disable-next-line no-unused-vars
19
- assignIds(ids) {
20
- throw new Error('assignIds must be implemented by subclass');
21
- }
22
-
23
- /**
24
- * Returns the list of CodeSystems this provider provides
25
- *
26
- * @param {string} fhirVersion - The FHIRVersion in scope - if relevant (there's always a stated version, though R5 is always used)
27
- * @param {string} context - The client's stated context - if provided.
28
- * @returns {Map<String, CodeSystem>} The list of CodeSystems
29
- * @throws {Error} Must be implemented by subclasses
30
- */
31
- // eslint-disable-next-line no-unused-vars
32
- async listCodeSystems(fhirVersion, context) {
33
- throw new Error('listCodeSystems must be implemented by AbstractCodeSystemProvider subclass');
34
- }
35
- }
36
-
37
- module.exports = {
38
- AbstractCodeSystemProvider
39
- };
@@ -0,0 +1,67 @@
1
+ const crypto = require('crypto');
2
+
3
+ function hashSortedLines(lines) {
4
+ const hash = crypto.createHash('sha256');
5
+ for (const line of lines.sort()) {
6
+ hash.update(line);
7
+ hash.update('\n');
8
+ }
9
+ return hash.digest('hex');
10
+ }
11
+
12
+ function computeCodeSystemFingerprint(concepts) {
13
+ if (!Array.isArray(concepts) || concepts.length === 0) {
14
+ return null;
15
+ }
16
+
17
+ const normalized = concepts
18
+ .map(concept => {
19
+ if (!concept || !concept.code) {
20
+ return null;
21
+ }
22
+
23
+ const code = String(concept.code || '');
24
+ const display = String(concept.display || '');
25
+ const definition = String(concept.definition || '');
26
+ const retired = concept.retired === true ? '1' : '0';
27
+ return `${code}|${display}|${definition}|${retired}`;
28
+ })
29
+ .filter(Boolean);
30
+
31
+ if (normalized.length === 0) {
32
+ return null;
33
+ }
34
+
35
+ return hashSortedLines(normalized);
36
+ }
37
+
38
+ function computeValueSetExpansionFingerprint(expansion) {
39
+ if (!expansion || !Array.isArray(expansion.contains) || expansion.contains.length === 0) {
40
+ return null;
41
+ }
42
+
43
+ const normalized = expansion.contains
44
+ .map(entry => {
45
+ if (!entry || !entry.code) {
46
+ return null;
47
+ }
48
+
49
+ const system = String(entry.system || '');
50
+ const code = String(entry.code || '');
51
+ const display = String(entry.display || '');
52
+ const inactive = entry.inactive === true ? '1' : '0';
53
+ return `${system}|${code}|${display}|${inactive}`;
54
+ })
55
+ .filter(Boolean);
56
+
57
+ if (normalized.length === 0) {
58
+ return null;
59
+ }
60
+
61
+ return hashSortedLines(normalized);
62
+ }
63
+
64
+ module.exports = {
65
+ computeCodeSystemFingerprint,
66
+ computeValueSetExpansionFingerprint
67
+ };
@@ -0,0 +1,2 @@
1
+ module.exports = require('./fingerprint.cjs');
2
+
@@ -0,0 +1,31 @@
1
+ const axios = require('axios');
2
+ const { DEFAULT_BASE_URL } = require('../shared/constants');
3
+
4
+ function createOclHttpClient(config = {}) {
5
+ const options = typeof config === 'string' ? { baseUrl: config } : (config || {});
6
+
7
+ const baseUrl = (options.baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, '');
8
+ const headers = {
9
+ Accept: 'application/json',
10
+ 'User-Agent': 'FHIRSmith-OCL-Provider/1.0'
11
+ };
12
+
13
+ if (options.token) {
14
+ headers.Authorization = options.token.startsWith('Token ') || options.token.startsWith('Bearer ')
15
+ ? options.token
16
+ : `Token ${options.token}`;
17
+ }
18
+
19
+ return {
20
+ baseUrl,
21
+ client: axios.create({
22
+ baseURL: baseUrl,
23
+ timeout: options.timeout || 30000,
24
+ headers
25
+ })
26
+ };
27
+ }
28
+
29
+ module.exports = {
30
+ createOclHttpClient
31
+ };
@@ -0,0 +1,2 @@
1
+ module.exports = require('./client.cjs');
2
+
@@ -0,0 +1,98 @@
1
+ const { PAGE_SIZE } = require('../shared/constants');
2
+
3
+ function extractItemsAndNext(payload, baseUrl = null) {
4
+ if (Array.isArray(payload)) {
5
+ return { items: payload, next: null };
6
+ }
7
+
8
+ if (!payload || typeof payload !== 'object') {
9
+ return { items: [], next: null };
10
+ }
11
+
12
+ const items = Array.isArray(payload.results)
13
+ ? payload.results
14
+ : Array.isArray(payload.items)
15
+ ? payload.items
16
+ : Array.isArray(payload.data)
17
+ ? payload.data
18
+ : [];
19
+
20
+ const next = payload.next || null;
21
+ if (!next) {
22
+ return { items, next: null };
23
+ }
24
+
25
+ if (baseUrl && typeof next === 'string' && next.startsWith(baseUrl)) {
26
+ return { items, next: next.replace(baseUrl, '') };
27
+ }
28
+
29
+ return { items, next };
30
+ }
31
+
32
+ async function fetchAllPages(httpClient, path, options = {}) {
33
+ const {
34
+ params = {},
35
+ pageSize = PAGE_SIZE,
36
+ maxPages = Number.MAX_SAFE_INTEGER,
37
+ baseUrl = null,
38
+ useNextLinks = true,
39
+ logger = null,
40
+ loggerPrefix = '[OCL]'
41
+ } = options;
42
+
43
+ const results = [];
44
+ let page = 1;
45
+ let nextPath = path;
46
+ let pageCount = 0;
47
+ let usePageMode = true;
48
+
49
+ while (nextPath && pageCount < maxPages) {
50
+ try {
51
+ const response = usePageMode
52
+ ? await httpClient.get(path, { params: { ...params, page, limit: pageSize } })
53
+ : await httpClient.get(nextPath);
54
+
55
+ if (Array.isArray(response.data)) {
56
+ results.push(...response.data);
57
+ pageCount += 1;
58
+
59
+ if (response.data.length < pageSize) {
60
+ break;
61
+ }
62
+
63
+ page += 1;
64
+ nextPath = path;
65
+ continue;
66
+ }
67
+
68
+ const { items, next } = extractItemsAndNext(response.data, baseUrl);
69
+ results.push(...items);
70
+ pageCount += 1;
71
+
72
+ if (useNextLinks && next) {
73
+ usePageMode = false;
74
+ nextPath = next;
75
+ continue;
76
+ }
77
+
78
+ if (usePageMode && items.length >= pageSize && pageCount < maxPages) {
79
+ page += 1;
80
+ nextPath = path;
81
+ } else {
82
+ break;
83
+ }
84
+ } catch (error) {
85
+ if (logger && typeof logger.error === 'function') {
86
+ logger.error(`${loggerPrefix} Fetch error on page ${page}:`, error.message);
87
+ }
88
+ throw error;
89
+ }
90
+ }
91
+
92
+ return results;
93
+ }
94
+
95
+ module.exports = {
96
+ extractItemsAndNext,
97
+ fetchAllPages
98
+ };
@@ -0,0 +1,2 @@
1
+ module.exports = require('./pagination.cjs');
2
+
@@ -0,0 +1,200 @@
1
+ class OCLBackgroundJobQueue {
2
+ static MAX_CONCURRENT = 2;
3
+ static HEARTBEAT_INTERVAL_MS = 30000;
4
+ static UNKNOWN_JOB_SIZE = Number.MAX_SAFE_INTEGER;
5
+ static pendingJobs = [];
6
+ static activeCount = 0;
7
+ static queuedOrRunningKeys = new Set();
8
+ static activeJobs = new Map();
9
+ static heartbeatTimer = null;
10
+ static enqueueSequence = 0;
11
+
12
+ static enqueue(jobKey, jobType, runJob, options = {}) {
13
+ if (!jobKey || typeof runJob !== 'function') {
14
+ return false;
15
+ }
16
+
17
+ if (this.queuedOrRunningKeys.has(jobKey)) {
18
+ return false;
19
+ }
20
+
21
+ this.queuedOrRunningKeys.add(jobKey);
22
+ const resolveAndEnqueue = async () => {
23
+ const resolvedSize = await this.#resolveJobSize(options);
24
+ const normalizedSize = this.#normalizeJobSize(resolvedSize);
25
+ this.#insertPendingJobOrdered({
26
+ jobKey,
27
+ jobType: jobType || 'background-job',
28
+ jobId: options?.jobId || jobKey,
29
+ jobSize: normalizedSize,
30
+ getProgress: typeof options?.getProgress === 'function' ? options.getProgress : null,
31
+ runJob,
32
+ enqueueOrder: this.enqueueSequence++
33
+ });
34
+ this.ensureHeartbeatRunning();
35
+ console.log(`[OCL] ${jobType || 'Background job'} enqueued: ${jobKey} (size=${normalizedSize}, queue=${this.pendingJobs.length}, active=${this.activeCount})`);
36
+ this.processNext();
37
+ };
38
+
39
+ Promise.resolve()
40
+ .then(resolveAndEnqueue)
41
+ .catch((error) => {
42
+ this.queuedOrRunningKeys.delete(jobKey);
43
+ const message = error && error.message ? error.message : String(error);
44
+ console.error(`[OCL] Failed to enqueue background job: ${jobType || 'background-job'} ${jobKey}: ${message}`);
45
+ });
46
+
47
+ return true;
48
+ }
49
+
50
+ static async #resolveJobSize(options = {}) {
51
+ if (typeof options?.resolveJobSize === 'function') {
52
+ try {
53
+ return await options.resolveJobSize();
54
+ } catch (_error) {
55
+ return this.UNKNOWN_JOB_SIZE;
56
+ }
57
+ }
58
+
59
+ if (options && Object.prototype.hasOwnProperty.call(options, 'jobSize')) {
60
+ return options.jobSize;
61
+ }
62
+
63
+ return this.UNKNOWN_JOB_SIZE;
64
+ }
65
+
66
+ static #normalizeJobSize(jobSize) {
67
+ const parsed = Number.parseInt(jobSize, 10);
68
+ if (!Number.isFinite(parsed) || parsed < 0) {
69
+ return this.UNKNOWN_JOB_SIZE;
70
+ }
71
+ return parsed;
72
+ }
73
+
74
+ static #insertPendingJobOrdered(job) {
75
+ let index = this.pendingJobs.findIndex(existing => {
76
+ if (existing.jobSize === job.jobSize) {
77
+ return existing.enqueueOrder > job.enqueueOrder;
78
+ }
79
+ return existing.jobSize > job.jobSize;
80
+ });
81
+
82
+ if (index < 0) {
83
+ index = this.pendingJobs.length;
84
+ }
85
+
86
+ this.pendingJobs.splice(index, 0, job);
87
+ }
88
+
89
+ static isQueuedOrRunning(jobKey) {
90
+ return this.queuedOrRunningKeys.has(jobKey);
91
+ }
92
+
93
+ static ensureHeartbeatRunning() {
94
+ if (this.heartbeatTimer) {
95
+ return;
96
+ }
97
+
98
+ this.heartbeatTimer = setInterval(() => {
99
+ this.logHeartbeat();
100
+ }, this.HEARTBEAT_INTERVAL_MS);
101
+
102
+ if (typeof this.heartbeatTimer.unref === 'function') {
103
+ this.heartbeatTimer.unref();
104
+ }
105
+ }
106
+
107
+ static logHeartbeat() {
108
+ const activeJobs = Array.from(this.activeJobs.values());
109
+ const lines = [
110
+ '[OCL] OCL background status:',
111
+ ` active jobs: ${activeJobs.length}`,
112
+ ` queued jobs: ${this.pendingJobs.length}`
113
+ ];
114
+
115
+ activeJobs.forEach((job, index) => {
116
+ lines.push('');
117
+ lines.push(` job ${index + 1}:`);
118
+ lines.push(` type: ${job.jobType || 'background-job'}`);
119
+ lines.push(` id: ${job.jobId || job.jobKey}`);
120
+ lines.push(` size: ${job.jobSize}`);
121
+ lines.push(` progress: ${this.formatProgress(job.getProgress)}`);
122
+ });
123
+
124
+ console.log(lines.join('\n'));
125
+ }
126
+
127
+ static formatProgress(getProgress) {
128
+ if (typeof getProgress !== 'function') {
129
+ return 'unknown';
130
+ }
131
+
132
+ try {
133
+ const progress = getProgress();
134
+ if (typeof progress === 'number' && Number.isFinite(progress)) {
135
+ const bounded = Math.max(0, Math.min(100, progress));
136
+ return `${Math.round(bounded)}%`;
137
+ }
138
+
139
+ if (progress && typeof progress === 'object') {
140
+ if (typeof progress.percentage === 'number' && Number.isFinite(progress.percentage)) {
141
+ const bounded = Math.max(0, Math.min(100, progress.percentage));
142
+ return `${Math.round(bounded)}%`;
143
+ }
144
+
145
+ if (
146
+ typeof progress.processed === 'number' &&
147
+ Number.isFinite(progress.processed) &&
148
+ typeof progress.total === 'number' &&
149
+ Number.isFinite(progress.total) &&
150
+ progress.total > 0
151
+ ) {
152
+ const ratio = progress.processed / progress.total;
153
+ const bounded = Math.max(0, Math.min(100, ratio * 100));
154
+ return `${Math.round(bounded)}%`;
155
+ }
156
+ }
157
+ } catch (_error) {
158
+ return 'unknown';
159
+ }
160
+
161
+ return 'unknown';
162
+ }
163
+
164
+ static processNext() {
165
+ while (this.activeCount < this.MAX_CONCURRENT && this.pendingJobs.length > 0) {
166
+ const job = this.pendingJobs.shift();
167
+ this.activeCount += 1;
168
+ this.activeJobs.set(job.jobKey, {
169
+ jobKey: job.jobKey,
170
+ jobType: job.jobType,
171
+ jobId: job.jobId || job.jobKey,
172
+ jobSize: job.jobSize,
173
+ getProgress: job.getProgress || null,
174
+ startedAt: Date.now()
175
+ });
176
+ console.log(`[OCL] Background job started: ${job.jobType} ${job.jobKey} (size=${job.jobSize}, queue=${this.pendingJobs.length}, active=${this.activeCount})`);
177
+
178
+ Promise.resolve()
179
+ .then(() => job.runJob())
180
+ .then(() => {
181
+ console.log(`[OCL] Background job completed: ${job.jobType} ${job.jobKey}`);
182
+ })
183
+ .catch((error) => {
184
+ const message = error && error.message ? error.message : String(error);
185
+ console.error(`[OCL] Background job failed: ${job.jobType} ${job.jobKey}: ${message}`);
186
+ })
187
+ .finally(() => {
188
+ this.activeCount -= 1;
189
+ this.queuedOrRunningKeys.delete(job.jobKey);
190
+ this.activeJobs.delete(job.jobKey);
191
+ console.log(`[OCL] Background queue status: queue=${this.pendingJobs.length}, active=${this.activeCount}`);
192
+ this.processNext();
193
+ });
194
+ }
195
+ }
196
+ }
197
+
198
+ module.exports = {
199
+ OCLBackgroundJobQueue
200
+ };
@@ -0,0 +1,2 @@
1
+ module.exports = require('./background-queue.cjs');
2
+
@@ -0,0 +1,66 @@
1
+ function toConceptContext(concept) {
2
+ if (!concept || typeof concept !== 'object') {
3
+ return null;
4
+ }
5
+
6
+ const code = concept.code || concept.id || null;
7
+ if (!code) {
8
+ return null;
9
+ }
10
+
11
+ return {
12
+ code,
13
+ display: concept.display_name || concept.display || concept.name || null,
14
+ definition: concept.description || concept.definition || null,
15
+ retired: concept.retired === true,
16
+ designations: extractDesignations(concept)
17
+ };
18
+ }
19
+
20
+ function extractDesignations(concept) {
21
+ const result = [];
22
+ const seen = new Set();
23
+
24
+ const add = (language, value) => {
25
+ const text = typeof value === 'string' ? value.trim() : '';
26
+ if (!text) {
27
+ return;
28
+ }
29
+
30
+ const lang = typeof language === 'string' ? language.trim() : '';
31
+ const key = `${lang}|${text}`;
32
+ if (seen.has(key)) {
33
+ return;
34
+ }
35
+
36
+ seen.add(key);
37
+ result.push({ language: lang, value: text });
38
+ };
39
+
40
+ if (Array.isArray(concept.names)) {
41
+ for (const entry of concept.names) {
42
+ if (!entry || typeof entry !== 'object') {
43
+ continue;
44
+ }
45
+
46
+ add(entry.locale || entry.language || entry.lang || '', entry.name || entry.display_name || entry.display || entry.value || entry.term);
47
+ }
48
+ }
49
+
50
+ if (concept.display_name || concept.display || concept.name) {
51
+ add(concept.locale || concept.default_locale || concept.language || '', concept.display_name || concept.display || concept.name);
52
+ }
53
+
54
+ if (concept.locale_display_names && typeof concept.locale_display_names === 'object') {
55
+ for (const [lang, value] of Object.entries(concept.locale_display_names)) {
56
+ add(lang, value);
57
+ }
58
+ }
59
+
60
+ return result;
61
+ }
62
+
63
+ module.exports = {
64
+ toConceptContext,
65
+ extractDesignations
66
+ };
@@ -0,0 +1,2 @@
1
+ module.exports = require('./concept-mapper.cjs');
2
+
@@ -0,0 +1,51 @@
1
+ class OCLConceptFilterContext {
2
+ constructor() {
3
+ this.concepts = [];
4
+ this.currentIndex = -1;
5
+ }
6
+
7
+ add(concept, rating = 0) {
8
+ this.concepts.push({ concept, rating });
9
+ }
10
+
11
+ sort() {
12
+ this.concepts.sort((a, b) => b.rating - a.rating);
13
+ }
14
+
15
+ size() {
16
+ return this.concepts.length;
17
+ }
18
+
19
+ hasMore() {
20
+ return this.currentIndex + 1 < this.concepts.length;
21
+ }
22
+
23
+ next() {
24
+ if (!this.hasMore()) {
25
+ return null;
26
+ }
27
+ this.currentIndex += 1;
28
+ return this.concepts[this.currentIndex].concept;
29
+ }
30
+
31
+ reset() {
32
+ this.currentIndex = -1;
33
+ }
34
+
35
+ findConceptByCode(code) {
36
+ for (const item of this.concepts) {
37
+ if (item.concept && item.concept.code === code) {
38
+ return item.concept;
39
+ }
40
+ }
41
+ return null;
42
+ }
43
+
44
+ containsConcept(concept) {
45
+ return this.concepts.some(item => item.concept === concept);
46
+ }
47
+ }
48
+
49
+ module.exports = {
50
+ OCLConceptFilterContext
51
+ };
@@ -0,0 +1,2 @@
1
+ module.exports = require('./concept-filter-context.cjs');
2
+
@@ -0,0 +1,15 @@
1
+ const DEFAULT_BASE_URL = 'https://api.openconceptlab.org';
2
+ const PAGE_SIZE = 100;
3
+ const CONCEPT_PAGE_SIZE = 1000;
4
+ const FILTERED_CONCEPT_PAGE_SIZE = 200;
5
+ const COLD_CACHE_FRESHNESS_MS = 60 * 60 * 1000;
6
+ const OCL_CODESYSTEM_MARKER_EXTENSION = 'http://fhir.org/FHIRsmith/StructureDefinition/ocl-codesystem';
7
+
8
+ module.exports = {
9
+ DEFAULT_BASE_URL,
10
+ PAGE_SIZE,
11
+ CONCEPT_PAGE_SIZE,
12
+ FILTERED_CONCEPT_PAGE_SIZE,
13
+ COLD_CACHE_FRESHNESS_MS,
14
+ OCL_CODESYSTEM_MARKER_EXTENSION
15
+ };
@@ -0,0 +1,2 @@
1
+ module.exports = require('./constants.cjs');
2
+