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.
- package/CHANGELOG.md +20 -0
- package/README.md +2 -0
- package/configurations/projector.json +21 -0
- package/configurations/readme.md +5 -0
- package/library/package-manager.js +0 -2
- package/library/version-utilities.js +85 -0
- package/package.json +1 -1
- package/packages/package-crawler.js +44 -9
- package/packages/packages.js +1 -0
- package/registry/crawler.js +35 -14
- package/registry/registry.js +3 -0
- package/server.js +4 -0
- package/tx/README.md +4 -4
- package/tx/cs/cs-loinc.js +5 -2
- package/tx/cs/cs-provider-api.js +25 -1
- package/tx/cs/cs-provider-list.js +2 -2
- package/tx/library/canonical-resource.js +6 -1
- package/tx/library.js +127 -10
- package/tx/ocl/README.md +236 -0
- package/tx/ocl/cache/cache-paths.cjs +32 -0
- package/tx/ocl/cache/cache-paths.js +2 -0
- package/tx/ocl/cache/cache-utils.cjs +43 -0
- package/tx/ocl/cache/cache-utils.js +2 -0
- package/tx/ocl/cm-ocl.cjs +531 -0
- package/tx/ocl/cm-ocl.js +1 -105
- package/tx/ocl/cs-ocl.cjs +1779 -0
- package/tx/ocl/cs-ocl.js +1 -38
- package/tx/ocl/fingerprint/fingerprint.cjs +67 -0
- package/tx/ocl/fingerprint/fingerprint.js +2 -0
- package/tx/ocl/http/client.cjs +31 -0
- package/tx/ocl/http/client.js +2 -0
- package/tx/ocl/http/pagination.cjs +98 -0
- package/tx/ocl/http/pagination.js +2 -0
- package/tx/ocl/jobs/background-queue.cjs +200 -0
- package/tx/ocl/jobs/background-queue.js +2 -0
- package/tx/ocl/mappers/concept-mapper.cjs +66 -0
- package/tx/ocl/mappers/concept-mapper.js +2 -0
- package/tx/ocl/model/concept-filter-context.cjs +51 -0
- package/tx/ocl/model/concept-filter-context.js +2 -0
- package/tx/ocl/shared/constants.cjs +15 -0
- package/tx/ocl/shared/constants.js +2 -0
- package/tx/ocl/shared/patches.cjs +224 -0
- package/tx/ocl/shared/patches.js +2 -0
- package/tx/ocl/vs-ocl.cjs +1848 -0
- package/tx/ocl/vs-ocl.js +1 -104
- package/tx/operation-context.js +8 -1
- package/tx/params.js +24 -3
- package/tx/provider.js +47 -0
- package/tx/tx-html.js +1 -1
- package/tx/tx.js +8 -0
- package/tx/vs/vs-vsac.js +4 -3
- package/tx/workers/batch-validate.js +3 -2
- package/tx/workers/batch.js +3 -2
- package/tx/workers/expand.js +64 -9
- package/tx/workers/lookup.js +5 -4
- package/tx/workers/read.js +2 -1
- package/tx/workers/related.js +3 -2
- package/tx/workers/search.js +4 -9
- package/tx/workers/subsumes.js +3 -2
- package/tx/workers/translate.js +4 -3
- package/tx/workers/validate.js +132 -40
- package/tx/workers/worker.js +1 -7
|
@@ -0,0 +1,1779 @@
|
|
|
1
|
+
const fs = require('fs/promises');
|
|
2
|
+
const { AbstractCodeSystemProvider } = require('../cs/cs-provider-api');
|
|
3
|
+
const { CodeSystemProvider, CodeSystemFactoryProvider, CodeSystemContentMode, FilterExecutionContext } = require('../cs/cs-api');
|
|
4
|
+
const { CodeSystem } = require('../library/codesystem');
|
|
5
|
+
const { SearchFilterText } = require('../library/designations');
|
|
6
|
+
const { PAGE_SIZE, CONCEPT_PAGE_SIZE, COLD_CACHE_FRESHNESS_MS, OCL_CODESYSTEM_MARKER_EXTENSION } = require('./shared/constants');
|
|
7
|
+
const { createOclHttpClient } = require('./http/client');
|
|
8
|
+
const { fetchAllPages, extractItemsAndNext } = require('./http/pagination');
|
|
9
|
+
const { CACHE_CS_DIR, CACHE_VS_DIR, getCacheFilePath } = require('./cache/cache-paths');
|
|
10
|
+
const { ensureCacheDirectories, getColdCacheAgeMs, formatCacheAgeMinutes } = require('./cache/cache-utils');
|
|
11
|
+
const { computeCodeSystemFingerprint } = require('./fingerprint/fingerprint');
|
|
12
|
+
const { OCLBackgroundJobQueue } = require('./jobs/background-queue');
|
|
13
|
+
const { OCLConceptFilterContext } = require('./model/concept-filter-context');
|
|
14
|
+
const { toConceptContext } = require('./mappers/concept-mapper');
|
|
15
|
+
const { patchSearchWorkerForOCLCodeFiltering } = require('./shared/patches');
|
|
16
|
+
|
|
17
|
+
patchSearchWorkerForOCLCodeFiltering();
|
|
18
|
+
|
|
19
|
+
function normalizeCanonicalSystem(system) {
|
|
20
|
+
if (typeof system !== 'string') {
|
|
21
|
+
return system;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const trimmed = system.trim();
|
|
25
|
+
if (!trimmed) {
|
|
26
|
+
return trimmed;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Treat canonical URLs with and without trailing slash as equivalent.
|
|
30
|
+
return trimmed.replace(/\/+$/, '');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
class OCLCodeSystemProvider extends AbstractCodeSystemProvider {
|
|
34
|
+
constructor(config = {}) {
|
|
35
|
+
super();
|
|
36
|
+
const options = typeof config === 'string' ? { baseUrl: config } : (config || {});
|
|
37
|
+
|
|
38
|
+
this.org = options.org || null;
|
|
39
|
+
const http = createOclHttpClient(options);
|
|
40
|
+
this.baseUrl = http.baseUrl;
|
|
41
|
+
this.httpClient = http.client;
|
|
42
|
+
|
|
43
|
+
this._codeSystemsByCanonical = new Map();
|
|
44
|
+
this._idToCodeSystem = new Map();
|
|
45
|
+
this.sourceMetaByUrl = new Map();
|
|
46
|
+
this._sourceStateByCanonical = new Map();
|
|
47
|
+
this._usedIds = new Set();
|
|
48
|
+
this._refreshPromise = null;
|
|
49
|
+
this._pendingChanges = null;
|
|
50
|
+
this._initialized = false;
|
|
51
|
+
this._initializePromise = null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async initialize() {
|
|
55
|
+
if (this._initialized) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (this._initializePromise) {
|
|
60
|
+
await this._initializePromise;
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
this._initializePromise = (async () => {
|
|
65
|
+
try {
|
|
66
|
+
const sources = await this.#fetchSourcesForDiscovery();
|
|
67
|
+
console.log(`[OCL] Fetched ${sources.length} sources`);
|
|
68
|
+
|
|
69
|
+
const snapshot = this.#buildSourceSnapshot(sources);
|
|
70
|
+
this.#applySnapshot(snapshot);
|
|
71
|
+
|
|
72
|
+
console.log(`[OCL] Loaded ${this._codeSystemsByCanonical.size} code systems`);
|
|
73
|
+
this._initialized = true;
|
|
74
|
+
} catch (error) {
|
|
75
|
+
console.error(`[OCL] Initialization failed:`, error.message);
|
|
76
|
+
if (error.response) {
|
|
77
|
+
console.error(`[OCL] HTTP ${error.response.status}: ${error.response.statusText}`);
|
|
78
|
+
}
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
})();
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
await this._initializePromise;
|
|
85
|
+
} finally {
|
|
86
|
+
this._initializePromise = null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
assignIds(ids) {
|
|
91
|
+
this._usedIds.clear();
|
|
92
|
+
for (const cs of this._idToCodeSystem.values()) {
|
|
93
|
+
if (!cs.id || ids.has(`CodeSystem/${cs.id}`)) {
|
|
94
|
+
cs.id = String(ids.size);
|
|
95
|
+
cs.jsonObj.id = cs.id;
|
|
96
|
+
}
|
|
97
|
+
ids.add(`CodeSystem/${cs.id}`);
|
|
98
|
+
this._usedIds.add(cs.id);
|
|
99
|
+
this._idToCodeSystem.set(cs.id, cs);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// eslint-disable-next-line no-unused-vars
|
|
104
|
+
async listCodeSystems(_fhirVersion, _context) {
|
|
105
|
+
await this.initialize();
|
|
106
|
+
return Array.from(this._codeSystemsByCanonical.values());
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// eslint-disable-next-line no-unused-vars
|
|
110
|
+
async loadCodeSystems(fhirVersion, context) {
|
|
111
|
+
return await this.listCodeSystems(fhirVersion, context);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Called once per minute by provider.updateCodeSystemList().
|
|
115
|
+
// That caller is currently sync, so we stage async fetches and return the latest ready diff.
|
|
116
|
+
// eslint-disable-next-line no-unused-vars
|
|
117
|
+
getCodeSystemChanges(_fhirVersion, _context) {
|
|
118
|
+
if (!this._initialized) {
|
|
119
|
+
return this.#emptyChanges();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
this.#scheduleRefresh();
|
|
123
|
+
if (!this._pendingChanges) {
|
|
124
|
+
return this.#emptyChanges();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const out = this._pendingChanges;
|
|
128
|
+
this._pendingChanges = null;
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async close() {
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
getSourceMetas() {
|
|
136
|
+
return Array.from(this.sourceMetaByUrl.values());
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
#scheduleRefresh() {
|
|
140
|
+
if (this._refreshPromise) {
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
this._refreshPromise = (async () => {
|
|
145
|
+
try {
|
|
146
|
+
const sources = await this.#fetchSourcesForDiscovery();
|
|
147
|
+
const nextSnapshot = this.#buildSourceSnapshot(sources);
|
|
148
|
+
const changes = this.#diffSnapshots(this._sourceStateByCanonical, nextSnapshot);
|
|
149
|
+
this.#applySnapshot(nextSnapshot);
|
|
150
|
+
this._pendingChanges = changes;
|
|
151
|
+
} catch (error) {
|
|
152
|
+
console.error('[OCL] Incremental source refresh failed:', error.message);
|
|
153
|
+
this._pendingChanges = this.#emptyChanges();
|
|
154
|
+
} finally {
|
|
155
|
+
this._refreshPromise = null;
|
|
156
|
+
}
|
|
157
|
+
})();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
#emptyChanges() {
|
|
161
|
+
return { added: [], changed: [], deleted: [] };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
#buildSourceSnapshot(sources) {
|
|
165
|
+
const snapshot = new Map();
|
|
166
|
+
for (const source of sources || []) {
|
|
167
|
+
const cs = this.#toCodeSystem(source);
|
|
168
|
+
if (!cs) {
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const canonicalUrl = cs.url;
|
|
173
|
+
const meta = this.#buildSourceMeta(source, cs);
|
|
174
|
+
const checksum = this.#sourceChecksum(source);
|
|
175
|
+
snapshot.set(canonicalUrl, { cs, meta, checksum });
|
|
176
|
+
}
|
|
177
|
+
return snapshot;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async #fetchSourcesForDiscovery() {
|
|
181
|
+
const organizations = await this.#fetchOrganizationIds();
|
|
182
|
+
if (organizations.length === 0) {
|
|
183
|
+
// Fallback for OCL instances that expose global listing but not org listing.
|
|
184
|
+
return await this.#fetchAllPages('/sources/');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const allSources = [];
|
|
188
|
+
const seen = new Set();
|
|
189
|
+
|
|
190
|
+
for (const orgId of organizations) {
|
|
191
|
+
const endpoint = `/orgs/${encodeURIComponent(orgId)}/sources/`;
|
|
192
|
+
const sources = await this.#fetchAllPages(endpoint);
|
|
193
|
+
for (const source of sources) {
|
|
194
|
+
if (!source || typeof source !== 'object') {
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
const key = this.#sourceIdentity(source);
|
|
198
|
+
if (seen.has(key)) {
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
seen.add(key);
|
|
202
|
+
allSources.push(source);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return allSources;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async #fetchOrganizationIds() {
|
|
210
|
+
const endpoint = '/orgs/';
|
|
211
|
+
const orgs = await this.#fetchAllPages(endpoint);
|
|
212
|
+
|
|
213
|
+
const ids = [];
|
|
214
|
+
const seen = new Set();
|
|
215
|
+
for (const org of orgs || []) {
|
|
216
|
+
if (!org || typeof org !== 'object') {
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const id = org.id || org.mnemonic || org.short_code || org.shortCode || org.name || null;
|
|
221
|
+
if (!id) {
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const normalized = String(id).trim();
|
|
226
|
+
if (!normalized || seen.has(normalized)) {
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
seen.add(normalized);
|
|
231
|
+
ids.push(normalized);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (ids.length === 0 && this.org) {
|
|
235
|
+
ids.push(this.org);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return ids;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
#sourceIdentity(source) {
|
|
242
|
+
if (!source || typeof source !== 'object') {
|
|
243
|
+
return '__invalid__';
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const owner = source.owner || '';
|
|
247
|
+
const canonical = normalizeCanonicalSystem(source.canonical_url || source.canonicalUrl || '');
|
|
248
|
+
const shortCode = source.short_code || source.shortCode || source.id || source.mnemonic || source.name || '';
|
|
249
|
+
return `${owner}|${canonical}|${shortCode}`;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
#applySnapshot(snapshot) {
|
|
253
|
+
const previousSnapshot = this._sourceStateByCanonical;
|
|
254
|
+
this._codeSystemsByCanonical.clear();
|
|
255
|
+
this._idToCodeSystem.clear();
|
|
256
|
+
this.sourceMetaByUrl.clear();
|
|
257
|
+
this._usedIds.clear();
|
|
258
|
+
|
|
259
|
+
for (const [canonicalUrl, entry] of snapshot.entries()) {
|
|
260
|
+
const cs = entry.cs;
|
|
261
|
+
const meta = entry.meta;
|
|
262
|
+
const previousEntry = previousSnapshot.get(canonicalUrl);
|
|
263
|
+
|
|
264
|
+
// Preserve complete-content marker if checksum did not change.
|
|
265
|
+
if (previousEntry && previousEntry.checksum === entry.checksum && previousEntry.cs?.jsonObj?.content === CodeSystemContentMode.Complete) {
|
|
266
|
+
cs.jsonObj.content = CodeSystemContentMode.Complete;
|
|
267
|
+
|
|
268
|
+
// Preserve materialized concepts across metadata refreshes.
|
|
269
|
+
if (Array.isArray(previousEntry.cs?.jsonObj?.concept)) {
|
|
270
|
+
cs.jsonObj.concept = previousEntry.cs.jsonObj.concept.map(concept => ({ ...concept }));
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// If a factory already has warm/cold concepts for this system, project them to the new snapshot resource.
|
|
275
|
+
OCLSourceCodeSystemFactory.syncCodeSystemResource(canonicalUrl, cs.version || null, cs);
|
|
276
|
+
|
|
277
|
+
this.#trackCodeSystemId(cs);
|
|
278
|
+
this._codeSystemsByCanonical.set(canonicalUrl, cs);
|
|
279
|
+
this._idToCodeSystem.set(cs.id, cs);
|
|
280
|
+
this.sourceMetaByUrl.set(canonicalUrl, meta);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
this._sourceStateByCanonical = snapshot;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
#diffSnapshots(previousSnapshot, nextSnapshot) {
|
|
287
|
+
const added = [];
|
|
288
|
+
const changed = [];
|
|
289
|
+
const deleted = [];
|
|
290
|
+
|
|
291
|
+
for (const [canonicalUrl, nextEntry] of nextSnapshot.entries()) {
|
|
292
|
+
const previousEntry = previousSnapshot.get(canonicalUrl);
|
|
293
|
+
if (!previousEntry) {
|
|
294
|
+
added.push(nextEntry.cs);
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Keep stable ids across revisions so clients don't observe resource id churn.
|
|
299
|
+
nextEntry.cs.id = previousEntry.cs.id;
|
|
300
|
+
nextEntry.cs.jsonObj.id = previousEntry.cs.id;
|
|
301
|
+
|
|
302
|
+
const previousChecksum = previousEntry.checksum || null;
|
|
303
|
+
const nextChecksum = nextEntry.checksum || null;
|
|
304
|
+
const checksumChanged = previousChecksum !== nextChecksum;
|
|
305
|
+
const versionChanged = (previousEntry.cs.version || null) !== (nextEntry.cs.version || null);
|
|
306
|
+
if (checksumChanged || versionChanged) {
|
|
307
|
+
if (checksumChanged) {
|
|
308
|
+
console.log(`[OCL] CodeSystem checksum changed: ${canonicalUrl} (${previousChecksum} -> ${nextChecksum})`);
|
|
309
|
+
}
|
|
310
|
+
changed.push(nextEntry.cs);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
for (const [canonicalUrl, previousEntry] of previousSnapshot.entries()) {
|
|
315
|
+
if (!nextSnapshot.has(canonicalUrl)) {
|
|
316
|
+
deleted.push(previousEntry.cs);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return { added, changed, deleted };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
#trackCodeSystemId(cs) {
|
|
324
|
+
if (!cs) {
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (!cs.id || this._usedIds.has(cs.id)) {
|
|
329
|
+
const raw = cs.id || cs.name || cs.url || 'ocl-cs';
|
|
330
|
+
const base = this.spaceId ? `${this.spaceId}-${raw}` : String(raw);
|
|
331
|
+
let candidate = base;
|
|
332
|
+
let index = 1;
|
|
333
|
+
while (this._usedIds.has(candidate)) {
|
|
334
|
+
candidate = `${base}-${index}`;
|
|
335
|
+
index += 1;
|
|
336
|
+
}
|
|
337
|
+
cs.id = candidate;
|
|
338
|
+
cs.jsonObj.id = candidate;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
this._usedIds.add(cs.id);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
#toCodeSystem(source) {
|
|
345
|
+
if (!source || typeof source !== 'object') {
|
|
346
|
+
return null;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const canonicalUrl = normalizeCanonicalSystem(source.canonical_url || source.canonicalUrl || source.url);
|
|
350
|
+
if (!canonicalUrl) {
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const id = source.id || source.mnemonic;
|
|
355
|
+
if (!id) {
|
|
356
|
+
return null;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const lastUpdated = this.#toIsoDate(source.updated_at || source.updatedAt || source.updated_on || source.updatedOn);
|
|
360
|
+
|
|
361
|
+
const json = {
|
|
362
|
+
resourceType: 'CodeSystem',
|
|
363
|
+
id,
|
|
364
|
+
url: canonicalUrl,
|
|
365
|
+
version: source.version || null,
|
|
366
|
+
name: source.name || source.mnemonic || id,
|
|
367
|
+
title: source.full_name || source.fullName || source.name || source.mnemonic || id,
|
|
368
|
+
status: 'active',
|
|
369
|
+
experimental: source.experimental === true,
|
|
370
|
+
description: source.description || null,
|
|
371
|
+
publisher: source.owner || null,
|
|
372
|
+
caseSensitive: source.case_sensitive != null ? source.case_sensitive : (source.caseSensitive != null ? source.caseSensitive : true),
|
|
373
|
+
language: source.default_locale || source.defaultLocale || null,
|
|
374
|
+
filter: [
|
|
375
|
+
{
|
|
376
|
+
code: 'code',
|
|
377
|
+
description: 'Match concept code',
|
|
378
|
+
operator: ['=', 'in', 'regex'],
|
|
379
|
+
value: 'code'
|
|
380
|
+
},
|
|
381
|
+
{
|
|
382
|
+
code: 'display',
|
|
383
|
+
description: 'Match concept display text',
|
|
384
|
+
operator: ['=', 'in', 'regex'],
|
|
385
|
+
value: 'string'
|
|
386
|
+
},
|
|
387
|
+
{
|
|
388
|
+
code: 'definition',
|
|
389
|
+
description: 'Match concept definition text',
|
|
390
|
+
operator: ['=', 'in', 'regex'],
|
|
391
|
+
value: 'string'
|
|
392
|
+
},
|
|
393
|
+
{
|
|
394
|
+
code: 'inactive',
|
|
395
|
+
description: 'Match inactive (retired) status',
|
|
396
|
+
operator: ['=', 'in'],
|
|
397
|
+
value: 'boolean'
|
|
398
|
+
}
|
|
399
|
+
],
|
|
400
|
+
property: [
|
|
401
|
+
{
|
|
402
|
+
code: 'code',
|
|
403
|
+
uri: 'http://hl7.org/fhir/concept-properties#code',
|
|
404
|
+
description: 'Concept code',
|
|
405
|
+
type: 'code'
|
|
406
|
+
},
|
|
407
|
+
{
|
|
408
|
+
code: 'display',
|
|
409
|
+
description: 'Concept display text',
|
|
410
|
+
type: 'string'
|
|
411
|
+
},
|
|
412
|
+
{
|
|
413
|
+
code: 'definition',
|
|
414
|
+
description: 'Concept definition text',
|
|
415
|
+
type: 'string'
|
|
416
|
+
},
|
|
417
|
+
{
|
|
418
|
+
code: 'inactive',
|
|
419
|
+
uri: 'http://hl7.org/fhir/concept-properties#status',
|
|
420
|
+
description: 'Whether concept is inactive (retired)',
|
|
421
|
+
type: 'boolean'
|
|
422
|
+
}
|
|
423
|
+
],
|
|
424
|
+
extension: [
|
|
425
|
+
{
|
|
426
|
+
url: OCL_CODESYSTEM_MARKER_EXTENSION,
|
|
427
|
+
valueBoolean: true
|
|
428
|
+
}
|
|
429
|
+
],
|
|
430
|
+
content: 'not-present'
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
if (lastUpdated) {
|
|
434
|
+
json.meta = { lastUpdated };
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
return new CodeSystem(json, 'R5', true);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
#buildSourceMeta(source, cs) {
|
|
441
|
+
if (!source || !cs) {
|
|
442
|
+
return null;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const owner = source.owner || null;
|
|
446
|
+
const shortCode = source.short_code || source.shortCode || source.mnemonic || source.id || null;
|
|
447
|
+
const canonicalUrl = cs.url;
|
|
448
|
+
if (!canonicalUrl) {
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const conceptsUrl = this.#normalizePath(source.concepts_url || source.conceptsUrl || this.#buildConceptsPath(source));
|
|
453
|
+
const meta = {
|
|
454
|
+
id: source.id || shortCode,
|
|
455
|
+
shortCode,
|
|
456
|
+
owner,
|
|
457
|
+
name: source.name || shortCode || cs.id,
|
|
458
|
+
description: source.description || null,
|
|
459
|
+
canonicalUrl,
|
|
460
|
+
version: source.version || null,
|
|
461
|
+
conceptsUrl,
|
|
462
|
+
checksum: this.#sourceChecksum(source),
|
|
463
|
+
codeSystem: cs
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
return meta;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
#sourceChecksum(source) {
|
|
470
|
+
// NOTE: OCL checksums are NOT reliable for cache invalidation decisions.
|
|
471
|
+
// They do not update when concepts are added or modified.
|
|
472
|
+
// This checksum is logged for debugging purposes only.
|
|
473
|
+
// Cache decisions are based on custom fingerprints computed from concept content.
|
|
474
|
+
|
|
475
|
+
if (!source || typeof source !== 'object') {
|
|
476
|
+
return null;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const checksums = source.checksums || {};
|
|
480
|
+
const standard = checksums.standard || null;
|
|
481
|
+
const smart = checksums.smart || null;
|
|
482
|
+
if (standard) {
|
|
483
|
+
return String(standard);
|
|
484
|
+
}
|
|
485
|
+
if (smart) {
|
|
486
|
+
return String(smart);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (source.checksum) {
|
|
490
|
+
return String(source.checksum);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const updated = source.updated_at || source.updatedAt || source.updated_on || source.updatedOn || null;
|
|
494
|
+
const version = source.version || null;
|
|
495
|
+
if (updated || version) {
|
|
496
|
+
return `${updated || ''}|${version || ''}`;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
return null;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
#buildConceptsPath(source) {
|
|
503
|
+
if (!source || typeof source !== 'object') {
|
|
504
|
+
return null;
|
|
505
|
+
}
|
|
506
|
+
const owner = source.owner || null;
|
|
507
|
+
const sourceId = source.short_code || source.shortCode || source.id || source.mnemonic || null;
|
|
508
|
+
if (!owner || !sourceId) {
|
|
509
|
+
const sourceUrl = source.url;
|
|
510
|
+
if (!sourceUrl || typeof sourceUrl !== 'string') {
|
|
511
|
+
return null;
|
|
512
|
+
}
|
|
513
|
+
const trimmed = sourceUrl.endsWith('/') ? sourceUrl : `${sourceUrl}/`;
|
|
514
|
+
return `${trimmed}concepts/`;
|
|
515
|
+
}
|
|
516
|
+
return `/orgs/${encodeURIComponent(owner)}/sources/${encodeURIComponent(sourceId)}/concepts/`;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
#normalizePath(pathValue) {
|
|
520
|
+
if (!pathValue) {
|
|
521
|
+
return null;
|
|
522
|
+
}
|
|
523
|
+
if (typeof pathValue !== 'string') {
|
|
524
|
+
return null;
|
|
525
|
+
}
|
|
526
|
+
if (pathValue.startsWith('http://') || pathValue.startsWith('https://')) {
|
|
527
|
+
return pathValue;
|
|
528
|
+
}
|
|
529
|
+
return `${this.baseUrl}${pathValue.startsWith('/') ? '' : '/'}${pathValue}`;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
async #fetchAllPages(path) {
|
|
533
|
+
try {
|
|
534
|
+
const result = await fetchAllPages(this.httpClient, path, {
|
|
535
|
+
pageSize: PAGE_SIZE,
|
|
536
|
+
baseUrl: this.baseUrl,
|
|
537
|
+
logger: console,
|
|
538
|
+
loggerPrefix: '[OCL]'
|
|
539
|
+
});
|
|
540
|
+
// Verificação extra: payload deve ser objeto ou array
|
|
541
|
+
if (!result || (typeof result !== 'object' && !Array.isArray(result))) {
|
|
542
|
+
throw new Error('[OCL] Invalid response format: expected object or array');
|
|
543
|
+
}
|
|
544
|
+
return result;
|
|
545
|
+
} catch (error) {
|
|
546
|
+
if (error.response) {
|
|
547
|
+
console.error(`[OCL] HTTP ${error.response.status}: ${error.response.statusText}`);
|
|
548
|
+
console.error('[OCL] Response:', error.response.data);
|
|
549
|
+
}
|
|
550
|
+
throw error;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
#extractItemsAndNext(payload) {
|
|
555
|
+
return extractItemsAndNext(payload, this.baseUrl);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
#toIsoDate(value) {
|
|
559
|
+
if (!value) {
|
|
560
|
+
return null;
|
|
561
|
+
}
|
|
562
|
+
const date = new Date(value);
|
|
563
|
+
if (Number.isNaN(date.getTime())) {
|
|
564
|
+
return null;
|
|
565
|
+
}
|
|
566
|
+
return date.toISOString();
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
class OCLSourceCodeSystemProvider extends CodeSystemProvider {
|
|
571
|
+
constructor(opContext, supplements, client, meta, sharedCaches = null) {
|
|
572
|
+
super(opContext, supplements);
|
|
573
|
+
this.httpClient = client;
|
|
574
|
+
this.meta = meta;
|
|
575
|
+
this.conceptCache = sharedCaches?.conceptCache || new Map();
|
|
576
|
+
this.pageCache = sharedCaches?.pageCache || new Map();
|
|
577
|
+
this.pendingConceptRequests = sharedCaches?.pendingConceptRequests || new Map();
|
|
578
|
+
this.pendingPageRequests = sharedCaches?.pendingPageRequests || new Map();
|
|
579
|
+
this.scheduleBackgroundLoad = typeof sharedCaches?.scheduleBackgroundLoad === 'function'
|
|
580
|
+
? sharedCaches.scheduleBackgroundLoad
|
|
581
|
+
: null;
|
|
582
|
+
this.isSystemComplete = typeof sharedCaches?.isSystemComplete === 'function'
|
|
583
|
+
? sharedCaches.isSystemComplete
|
|
584
|
+
: (() => false);
|
|
585
|
+
this.getTotalConceptCount = typeof sharedCaches?.getTotalConceptCount === 'function'
|
|
586
|
+
? sharedCaches.getTotalConceptCount
|
|
587
|
+
: (() => -1);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
system() {
|
|
591
|
+
return this.meta.canonicalUrl;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
version() {
|
|
595
|
+
return this.meta.version || null;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
description() {
|
|
599
|
+
return this.meta.description || null;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
name() {
|
|
603
|
+
return this.meta.name || this.meta.shortCode || this.meta.id || this.system();
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
contentMode() {
|
|
607
|
+
if (this.isSystemComplete()) {
|
|
608
|
+
return CodeSystemContentMode.Complete;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// OCL CodeSystems are lazily materialized. Even when metadata is still
|
|
612
|
+
// warming up, concepts remain fetchable through the source concepts URL.
|
|
613
|
+
// Report at least fragment support so $expand does not fail early with
|
|
614
|
+
// "has no content" before lazy retrieval/hydration can run.
|
|
615
|
+
const hasRealCodeSystemResource = this.meta?.codeSystem instanceof CodeSystem;
|
|
616
|
+
if (hasRealCodeSystemResource && (this.meta?.conceptsUrl || this.conceptCache.size > 0 || this.pageCache.size > 0)) {
|
|
617
|
+
return CodeSystemContentMode.Fragment;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
return CodeSystemContentMode.NotPresent;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
totalCount() {
|
|
624
|
+
return this.getTotalConceptCount();
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
propertyDefinitions() {
|
|
628
|
+
return this.meta?.codeSystem?.jsonObj?.property || null;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
async code(code) {
|
|
632
|
+
const ctxt = await this.#ensureContext(code);
|
|
633
|
+
return ctxt ? ctxt.code : null;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
async display(code) {
|
|
637
|
+
const ctxt = await this.#ensureContext(code);
|
|
638
|
+
if (!ctxt) {
|
|
639
|
+
return null;
|
|
640
|
+
}
|
|
641
|
+
if (ctxt.display && this.opContext.langs.isEnglishOrNothing()) {
|
|
642
|
+
return ctxt.display;
|
|
643
|
+
}
|
|
644
|
+
const supp = this._displayFromSupplements(ctxt.code);
|
|
645
|
+
return supp || ctxt.display || null;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
async definition(code) {
|
|
649
|
+
const ctxt = await this.#ensureContext(code);
|
|
650
|
+
return ctxt ? ctxt.definition : null;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
async isAbstract(code) {
|
|
654
|
+
await this.#ensureContext(code);
|
|
655
|
+
return false;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
async isInactive(code) {
|
|
659
|
+
const ctxt = await this.#ensureContext(code);
|
|
660
|
+
return ctxt ? ctxt.retired === true : false;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
async isDeprecated(code) {
|
|
664
|
+
await this.#ensureContext(code);
|
|
665
|
+
return false;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
async getStatus(code) {
|
|
669
|
+
const ctxt = await this.#ensureContext(code);
|
|
670
|
+
if (!ctxt) {
|
|
671
|
+
return null;
|
|
672
|
+
}
|
|
673
|
+
return ctxt.retired === true ? 'inactive' : 'active';
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
async designations(code, displays) {
|
|
677
|
+
const ctxt = await this.#ensureContext(code);
|
|
678
|
+
if (ctxt && ctxt.display) {
|
|
679
|
+
const hasConceptDesignations = Array.isArray(ctxt.designations) && ctxt.designations.length > 0;
|
|
680
|
+
if (hasConceptDesignations) {
|
|
681
|
+
for (const d of ctxt.designations) {
|
|
682
|
+
if (!d || !d.value) {
|
|
683
|
+
continue;
|
|
684
|
+
}
|
|
685
|
+
displays.addDesignation(true, 'active', d.language || '', CodeSystem.makeUseForDisplay(), d.value);
|
|
686
|
+
}
|
|
687
|
+
} else {
|
|
688
|
+
displays.addDesignation(true, 'active', 'en', CodeSystem.makeUseForDisplay(), ctxt.display);
|
|
689
|
+
}
|
|
690
|
+
this._listSupplementDesignations(ctxt.code, displays);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
async locate(code) {
|
|
695
|
+
if (!code || typeof code !== 'string') {
|
|
696
|
+
return { context: null, message: 'Empty code' };
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
if (this.conceptCache.has(code)) {
|
|
700
|
+
return { context: this.conceptCache.get(code), message: null };
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
if (this.scheduleBackgroundLoad) {
|
|
704
|
+
this.scheduleBackgroundLoad('lookup-miss');
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
const concept = await this.#fetchConcept(code);
|
|
708
|
+
if (!concept) {
|
|
709
|
+
return { context: null, message: undefined };
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
this.conceptCache.set(code, concept);
|
|
713
|
+
return { context: concept, message: null };
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
async iterator(code) {
|
|
717
|
+
await this.#ensureContext(code);
|
|
718
|
+
if (code) {
|
|
719
|
+
return null;
|
|
720
|
+
}
|
|
721
|
+
return {
|
|
722
|
+
page: 1,
|
|
723
|
+
index: 0,
|
|
724
|
+
items: [],
|
|
725
|
+
total: -1,
|
|
726
|
+
done: false
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
async iteratorAll() {
|
|
731
|
+
return this.iterator(null);
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
async getPrepContext(iterate) {
|
|
735
|
+
return new FilterExecutionContext(iterate);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
async doesFilter(prop, op, value) {
|
|
739
|
+
if (!prop || !op || value == null) {
|
|
740
|
+
return false;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
const normalizedProp = String(prop).trim().toLowerCase();
|
|
744
|
+
const normalizedOp = String(op).trim().toLowerCase();
|
|
745
|
+
const supportedOps = ['=', 'in', 'regex'];
|
|
746
|
+
if (!supportedOps.includes(normalizedOp)) {
|
|
747
|
+
return false;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
if (['concept', 'code', 'display', 'definition', 'inactive'].includes(normalizedProp)) {
|
|
751
|
+
return true;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
const defs = this.propertyDefinitions() || [];
|
|
755
|
+
return defs.some(def => def && def.code === normalizedProp);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
async searchFilter(filterContext, filter, sort) {
|
|
759
|
+
const matcher = this.#toSearchFilterText(filter);
|
|
760
|
+
const results = new OCLConceptFilterContext();
|
|
761
|
+
const concepts = await this.#allConceptContexts();
|
|
762
|
+
|
|
763
|
+
for (const concept of concepts) {
|
|
764
|
+
const text = this.#conceptSearchText(concept);
|
|
765
|
+
const match = matcher.passes(text, true);
|
|
766
|
+
if (!match || match.passes !== true) {
|
|
767
|
+
continue;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
results.add(concept, this.#searchRating(concept, matcher, match.rating));
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
if (sort === true) {
|
|
774
|
+
results.sort();
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
if (!Array.isArray(filterContext.filters)) {
|
|
778
|
+
filterContext.filters = [];
|
|
779
|
+
}
|
|
780
|
+
filterContext.filters.push(results);
|
|
781
|
+
return filterContext;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
async filter(filterContext, prop, op, value) {
|
|
785
|
+
const normalizedProp = String(prop || '').trim().toLowerCase();
|
|
786
|
+
const normalizedOp = String(op || '').trim().toLowerCase();
|
|
787
|
+
|
|
788
|
+
if (!await this.doesFilter(normalizedProp, normalizedOp, value)) {
|
|
789
|
+
throw new Error(`Filter ${prop} ${op} is not supported by OCL provider`);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
const set = new OCLConceptFilterContext();
|
|
793
|
+
const concepts = await this.#allConceptContexts();
|
|
794
|
+
const matcher = this.#buildPropertyMatcher(normalizedProp, normalizedOp, value);
|
|
795
|
+
|
|
796
|
+
for (const concept of concepts) {
|
|
797
|
+
if (matcher(concept)) {
|
|
798
|
+
set.add(concept, 0);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
if (!Array.isArray(filterContext.filters)) {
|
|
803
|
+
filterContext.filters = [];
|
|
804
|
+
}
|
|
805
|
+
filterContext.filters.push(set);
|
|
806
|
+
return set;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
async executeFilters(filterContext) {
|
|
810
|
+
return Array.isArray(filterContext?.filters) ? filterContext.filters : [];
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// eslint-disable-next-line no-unused-vars
|
|
814
|
+
async filterSize(filterContext, set) {
|
|
815
|
+
return set ? set.size() : 0;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// eslint-disable-next-line no-unused-vars
|
|
819
|
+
async filterMore(filterContext, set) {
|
|
820
|
+
return !!set && set.hasMore();
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// eslint-disable-next-line no-unused-vars
|
|
824
|
+
async filterConcept(filterContext, set) {
|
|
825
|
+
if (!set) {
|
|
826
|
+
return null;
|
|
827
|
+
}
|
|
828
|
+
return set.next();
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// eslint-disable-next-line no-unused-vars
|
|
832
|
+
async filterLocate(filterContext, set, code) {
|
|
833
|
+
if (!set) {
|
|
834
|
+
return `Code '${code}' not found: no filter results`;
|
|
835
|
+
}
|
|
836
|
+
const concept = set.findConceptByCode(code);
|
|
837
|
+
if (concept) {
|
|
838
|
+
return concept;
|
|
839
|
+
}
|
|
840
|
+
return null;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// eslint-disable-next-line no-unused-vars
|
|
844
|
+
async filterCheck(filterContext, set, concept) {
|
|
845
|
+
if (!set || !concept) {
|
|
846
|
+
return false;
|
|
847
|
+
}
|
|
848
|
+
return set.containsConcept(concept);
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
async filterFinish(filterContext) {
|
|
852
|
+
if (!Array.isArray(filterContext?.filters)) {
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
for (const set of filterContext.filters) {
|
|
856
|
+
if (set && typeof set.reset === 'function') {
|
|
857
|
+
set.reset();
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
filterContext.filters.length = 0;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
async nextContext(iteratorContext) {
|
|
864
|
+
if (!iteratorContext || iteratorContext.done) {
|
|
865
|
+
return null;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
if (iteratorContext.index >= iteratorContext.items.length) {
|
|
869
|
+
const pageItems = await this.#fetchConceptPage(iteratorContext.page);
|
|
870
|
+
iteratorContext.page += 1;
|
|
871
|
+
iteratorContext.index = 0;
|
|
872
|
+
iteratorContext.items = pageItems;
|
|
873
|
+
|
|
874
|
+
if (!pageItems || pageItems.length === 0) {
|
|
875
|
+
iteratorContext.done = true;
|
|
876
|
+
return null;
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
const concept = iteratorContext.items[iteratorContext.index];
|
|
881
|
+
iteratorContext.index += 1;
|
|
882
|
+
return concept;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
async #ensureContext(code) {
|
|
886
|
+
if (!code) {
|
|
887
|
+
return null;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// Some call paths pass a pending locate() Promise (or its wrapper result)
|
|
891
|
+
// instead of a raw code/context; normalize both shapes here.
|
|
892
|
+
if (code && typeof code === 'object' && typeof code.then === 'function') {
|
|
893
|
+
code = await code;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
if (code && typeof code === 'object' && Object.prototype.hasOwnProperty.call(code, 'context')) {
|
|
897
|
+
if (!code.context) {
|
|
898
|
+
throw new Error(code.message || 'Unknown code');
|
|
899
|
+
}
|
|
900
|
+
code = code.context;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
if (typeof code === 'string') {
|
|
904
|
+
const result = await this.locate(code);
|
|
905
|
+
if (!result.context) {
|
|
906
|
+
throw new Error(result.message || `Unknown code ${code}`);
|
|
907
|
+
}
|
|
908
|
+
return result.context;
|
|
909
|
+
}
|
|
910
|
+
if (code && typeof code === 'object' && code.code) {
|
|
911
|
+
return code;
|
|
912
|
+
}
|
|
913
|
+
throw new Error(`Unknown Type at #ensureContext: ${typeof code}`);
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
async #fetchConceptPage(page) {
|
|
917
|
+
if (!this.meta.conceptsUrl) {
|
|
918
|
+
return [];
|
|
919
|
+
}
|
|
920
|
+
const cacheKey = `${this.meta.conceptsUrl}|p=${page}|l=${CONCEPT_PAGE_SIZE}`;
|
|
921
|
+
if (this.pageCache.has(cacheKey)) {
|
|
922
|
+
const cached = this.pageCache.get(cacheKey);
|
|
923
|
+
return Array.isArray(cached)
|
|
924
|
+
? cached
|
|
925
|
+
: Array.isArray(cached?.concepts)
|
|
926
|
+
? cached.concepts
|
|
927
|
+
: [];
|
|
928
|
+
}
|
|
929
|
+
if (this.pendingPageRequests.has(cacheKey)) {
|
|
930
|
+
const pendingResult = await this.pendingPageRequests.get(cacheKey);
|
|
931
|
+
return Array.isArray(pendingResult)
|
|
932
|
+
? pendingResult
|
|
933
|
+
: Array.isArray(pendingResult?.concepts)
|
|
934
|
+
? pendingResult.concepts
|
|
935
|
+
: [];
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
if (this.scheduleBackgroundLoad) {
|
|
939
|
+
this.scheduleBackgroundLoad('page-miss');
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
const pending = (async () => {
|
|
943
|
+
let response;
|
|
944
|
+
try {
|
|
945
|
+
response = await this.httpClient.get(this.meta.conceptsUrl, { params: { page, limit: CONCEPT_PAGE_SIZE } });
|
|
946
|
+
} catch (error) {
|
|
947
|
+
// Some OCL instances return 404 for sources without concept listing endpoints.
|
|
948
|
+
// Treat this as an empty page so terminology operations degrade gracefully.
|
|
949
|
+
if (error && error.response && error.response.status === 404) {
|
|
950
|
+
this.pageCache.set(cacheKey, []);
|
|
951
|
+
return [];
|
|
952
|
+
}
|
|
953
|
+
throw error;
|
|
954
|
+
}
|
|
955
|
+
const payload = response.data;
|
|
956
|
+
const items = Array.isArray(payload)
|
|
957
|
+
? payload
|
|
958
|
+
: Array.isArray(payload?.results)
|
|
959
|
+
? payload.results
|
|
960
|
+
: Array.isArray(payload?.items)
|
|
961
|
+
? payload.items
|
|
962
|
+
: Array.isArray(payload?.data)
|
|
963
|
+
? payload.data
|
|
964
|
+
: [];
|
|
965
|
+
|
|
966
|
+
const mapped = items.map(item => this.#toConceptContext(item)).filter(Boolean);
|
|
967
|
+
this.pageCache.set(cacheKey, mapped);
|
|
968
|
+
for (const concept of mapped) {
|
|
969
|
+
if (concept && concept.code) {
|
|
970
|
+
this.conceptCache.set(concept.code, concept);
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
return mapped;
|
|
974
|
+
})();
|
|
975
|
+
|
|
976
|
+
this.pendingPageRequests.set(cacheKey, pending);
|
|
977
|
+
try {
|
|
978
|
+
return await pending;
|
|
979
|
+
} finally {
|
|
980
|
+
this.pendingPageRequests.delete(cacheKey);
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
async #fetchConcept(code) {
|
|
985
|
+
if (!this.meta.conceptsUrl) {
|
|
986
|
+
return null;
|
|
987
|
+
}
|
|
988
|
+
if (this.conceptCache.has(code)) {
|
|
989
|
+
return this.conceptCache.get(code);
|
|
990
|
+
}
|
|
991
|
+
const pendingKey = `${this.meta.conceptsUrl}|c=${code}`;
|
|
992
|
+
if (this.pendingConceptRequests.has(pendingKey)) {
|
|
993
|
+
return this.pendingConceptRequests.get(pendingKey);
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
if (this.scheduleBackgroundLoad) {
|
|
997
|
+
this.scheduleBackgroundLoad('concept-miss');
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
const url = this.#buildConceptUrl(code);
|
|
1001
|
+
const pending = (async () => {
|
|
1002
|
+
let response;
|
|
1003
|
+
try {
|
|
1004
|
+
response = await this.httpClient.get(url);
|
|
1005
|
+
} catch (error) {
|
|
1006
|
+
// Missing concept should be treated as not-found, not as an internal server failure.
|
|
1007
|
+
if (error && error.response && error.response.status === 404) {
|
|
1008
|
+
return null;
|
|
1009
|
+
}
|
|
1010
|
+
throw error;
|
|
1011
|
+
}
|
|
1012
|
+
const concept = this.#toConceptContext(response.data);
|
|
1013
|
+
if (concept && concept.code) {
|
|
1014
|
+
this.conceptCache.set(concept.code, concept);
|
|
1015
|
+
}
|
|
1016
|
+
return concept;
|
|
1017
|
+
})();
|
|
1018
|
+
|
|
1019
|
+
this.pendingConceptRequests.set(pendingKey, pending);
|
|
1020
|
+
try {
|
|
1021
|
+
return await pending;
|
|
1022
|
+
} finally {
|
|
1023
|
+
this.pendingConceptRequests.delete(pendingKey);
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
async #allConceptContexts() {
|
|
1028
|
+
const concepts = new Map();
|
|
1029
|
+
|
|
1030
|
+
for (const concept of this.conceptCache.values()) {
|
|
1031
|
+
if (concept && concept.code) {
|
|
1032
|
+
concepts.set(concept.code, concept);
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
// Ensure search can operate even when content is not fully warm-loaded.
|
|
1037
|
+
const iter = await this.iterator(null);
|
|
1038
|
+
let concept = await this.nextContext(iter);
|
|
1039
|
+
while (concept) {
|
|
1040
|
+
if (concept.code && !concepts.has(concept.code)) {
|
|
1041
|
+
concepts.set(concept.code, concept);
|
|
1042
|
+
}
|
|
1043
|
+
concept = await this.nextContext(iter);
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
return Array.from(concepts.values());
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
#toSearchFilterText(filter) {
|
|
1050
|
+
if (filter instanceof SearchFilterText) {
|
|
1051
|
+
return filter;
|
|
1052
|
+
}
|
|
1053
|
+
if (typeof filter === 'string') {
|
|
1054
|
+
return new SearchFilterText(filter);
|
|
1055
|
+
}
|
|
1056
|
+
if (filter && typeof filter.filter === 'string') {
|
|
1057
|
+
return new SearchFilterText(filter.filter);
|
|
1058
|
+
}
|
|
1059
|
+
return new SearchFilterText('');
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
#conceptSearchText(concept) {
|
|
1063
|
+
if (!concept || typeof concept !== 'object') {
|
|
1064
|
+
return '';
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
const values = [concept.code, concept.display, concept.definition];
|
|
1068
|
+
if (Array.isArray(concept.designations)) {
|
|
1069
|
+
for (const designation of concept.designations) {
|
|
1070
|
+
if (designation && designation.value) {
|
|
1071
|
+
values.push(designation.value);
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
return values.filter(Boolean).join(' ');
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
#searchRating(concept, matcher, baseRating) {
|
|
1080
|
+
let rating = Number.isFinite(baseRating) ? baseRating : 0;
|
|
1081
|
+
const term = matcher?.filter || '';
|
|
1082
|
+
if (!term) {
|
|
1083
|
+
return rating;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
const code = String(concept?.code || '').toLowerCase();
|
|
1087
|
+
const display = String(concept?.display || '').toLowerCase();
|
|
1088
|
+
const definition = String(concept?.definition || '').toLowerCase();
|
|
1089
|
+
|
|
1090
|
+
if (code === term || display === term) {
|
|
1091
|
+
rating += 100;
|
|
1092
|
+
} else if (code.startsWith(term) || display.startsWith(term)) {
|
|
1093
|
+
rating += 50;
|
|
1094
|
+
} else if (definition.includes(term)) {
|
|
1095
|
+
rating += 10;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
return rating;
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
#buildPropertyMatcher(prop, op, value) {
|
|
1102
|
+
if (op === 'regex') {
|
|
1103
|
+
const regex = new RegExp(String(value), 'i');
|
|
1104
|
+
return concept => {
|
|
1105
|
+
const candidate = this.#valueForFilter(concept, prop);
|
|
1106
|
+
if (candidate == null) {
|
|
1107
|
+
return false;
|
|
1108
|
+
}
|
|
1109
|
+
return regex.test(String(candidate));
|
|
1110
|
+
};
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
if (op === 'in') {
|
|
1114
|
+
const tokens = String(value)
|
|
1115
|
+
.split(',')
|
|
1116
|
+
.map(token => token.trim().toLowerCase())
|
|
1117
|
+
.filter(Boolean);
|
|
1118
|
+
return concept => {
|
|
1119
|
+
const candidate = this.#valueForFilter(concept, prop);
|
|
1120
|
+
if (candidate == null) {
|
|
1121
|
+
return false;
|
|
1122
|
+
}
|
|
1123
|
+
return tokens.includes(String(candidate).toLowerCase());
|
|
1124
|
+
};
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
if (prop === 'inactive') {
|
|
1128
|
+
const expected = this.#toBoolean(value);
|
|
1129
|
+
return concept => {
|
|
1130
|
+
const candidate = this.#toBoolean(this.#valueForFilter(concept, prop));
|
|
1131
|
+
return candidate === expected;
|
|
1132
|
+
};
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
const expected = String(value).toLowerCase();
|
|
1136
|
+
return concept => {
|
|
1137
|
+
const candidate = this.#valueForFilter(concept, prop);
|
|
1138
|
+
if (candidate == null) {
|
|
1139
|
+
return false;
|
|
1140
|
+
}
|
|
1141
|
+
return String(candidate).toLowerCase() === expected;
|
|
1142
|
+
};
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
#valueForFilter(concept, prop) {
|
|
1146
|
+
if (!concept || typeof concept !== 'object') {
|
|
1147
|
+
return null;
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
switch (prop) {
|
|
1151
|
+
case 'concept':
|
|
1152
|
+
case 'code':
|
|
1153
|
+
return concept.code || null;
|
|
1154
|
+
case 'display':
|
|
1155
|
+
return concept.display || null;
|
|
1156
|
+
case 'definition':
|
|
1157
|
+
return concept.definition || null;
|
|
1158
|
+
case 'inactive':
|
|
1159
|
+
return concept.retired === true;
|
|
1160
|
+
default:
|
|
1161
|
+
return concept[prop] ?? null;
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
#toBoolean(value) {
|
|
1166
|
+
if (typeof value === 'boolean') {
|
|
1167
|
+
return value;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
const text = String(value || '').trim().toLowerCase();
|
|
1171
|
+
return text === 'true' || text === '1' || text === 'yes';
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
#buildConceptUrl(code) {
|
|
1175
|
+
const base = this.meta.conceptsUrl.endsWith('/') ? this.meta.conceptsUrl : `${this.meta.conceptsUrl}/`;
|
|
1176
|
+
return `${base}${encodeURIComponent(code)}/`;
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
#toConceptContext(concept) {
|
|
1180
|
+
return toConceptContext(concept);
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
class OCLSourceCodeSystemFactory extends CodeSystemFactoryProvider {
|
|
1185
|
+
static factoriesByKey = new Map();
|
|
1186
|
+
|
|
1187
|
+
static #normalizeSystem(system) {
|
|
1188
|
+
return normalizeCanonicalSystem(system);
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
static hasFactory(system, version = null) {
|
|
1192
|
+
return !!OCLSourceCodeSystemFactory.#findFactory(system, version);
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
static hasExactFactory(system, version = null) {
|
|
1196
|
+
const normalizedSystem = OCLSourceCodeSystemFactory.#normalizeSystem(system);
|
|
1197
|
+
if (!normalizedSystem) {
|
|
1198
|
+
return false;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
const exactKey = `${normalizedSystem}|${version || ''}`;
|
|
1202
|
+
return OCLSourceCodeSystemFactory.factoriesByKey.has(exactKey);
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
static #findFactory(system, version = null) {
|
|
1206
|
+
const normalizedSystem = OCLSourceCodeSystemFactory.#normalizeSystem(system);
|
|
1207
|
+
if (!normalizedSystem) {
|
|
1208
|
+
return null;
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
const exactKey = `${normalizedSystem}|${version || ''}`;
|
|
1212
|
+
const exact = OCLSourceCodeSystemFactory.factoriesByKey.get(exactKey);
|
|
1213
|
+
if (exact) {
|
|
1214
|
+
return exact;
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
// When caller version does not match the registered one (or is absent),
|
|
1218
|
+
// still reuse the factory for the same canonical system.
|
|
1219
|
+
for (const [key, factory] of OCLSourceCodeSystemFactory.factoriesByKey.entries()) {
|
|
1220
|
+
if (!factory) {
|
|
1221
|
+
continue;
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
const separatorIndex = key.lastIndexOf('|');
|
|
1225
|
+
if (separatorIndex < 0) {
|
|
1226
|
+
continue;
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
const keySystem = OCLSourceCodeSystemFactory.#normalizeSystem(key.substring(0, separatorIndex));
|
|
1230
|
+
if (keySystem === normalizedSystem) {
|
|
1231
|
+
return factory;
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
return null;
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
static syncCodeSystemResource(system, version = null, codeSystem = null) {
|
|
1239
|
+
const normalizedSystem = OCLSourceCodeSystemFactory.#normalizeSystem(system);
|
|
1240
|
+
if (!normalizedSystem) {
|
|
1241
|
+
return;
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
const factory = OCLSourceCodeSystemFactory.#findFactory(normalizedSystem, version);
|
|
1245
|
+
if (!factory) {
|
|
1246
|
+
return;
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
factory.#applyConceptsToCodeSystemResource(codeSystem || factory.meta?.codeSystem || null);
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
constructor(i18n, client, meta) {
|
|
1253
|
+
super(i18n);
|
|
1254
|
+
this.httpClient = client;
|
|
1255
|
+
this.meta = meta;
|
|
1256
|
+
this.sharedConceptCache = new Map();
|
|
1257
|
+
this.sharedPageCache = new Map();
|
|
1258
|
+
this.sharedPendingConceptRequests = new Map();
|
|
1259
|
+
this.sharedPendingPageRequests = new Map();
|
|
1260
|
+
this.isComplete = meta?.codeSystem?.jsonObj?.content === CodeSystemContentMode.Complete;
|
|
1261
|
+
this.loadedConceptCount = -1;
|
|
1262
|
+
this.loadedChecksum = meta?.checksum || null;
|
|
1263
|
+
this.customFingerprint = null;
|
|
1264
|
+
this.backgroundLoadProgress = { processed: 0, total: null };
|
|
1265
|
+
this.materializedConceptList = null;
|
|
1266
|
+
this.materializedConceptCount = -1;
|
|
1267
|
+
OCLSourceCodeSystemFactory.factoriesByKey.set(this.#resourceKey(), this);
|
|
1268
|
+
|
|
1269
|
+
const unversionedKey = `${this.system()}|`;
|
|
1270
|
+
if (!OCLSourceCodeSystemFactory.factoriesByKey.has(unversionedKey)) {
|
|
1271
|
+
OCLSourceCodeSystemFactory.factoriesByKey.set(unversionedKey, this);
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
// Load cold cache at construction
|
|
1275
|
+
this.#loadColdCache();
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
async #loadColdCache() {
|
|
1279
|
+
const canonicalUrl = this.system();
|
|
1280
|
+
const version = this.version();
|
|
1281
|
+
const cacheFilePath = getCacheFilePath(CACHE_CS_DIR, canonicalUrl, version);
|
|
1282
|
+
|
|
1283
|
+
try {
|
|
1284
|
+
const data = await fs.readFile(cacheFilePath, 'utf-8');
|
|
1285
|
+
const cached = JSON.parse(data);
|
|
1286
|
+
|
|
1287
|
+
if (!cached || !cached.concepts || !Array.isArray(cached.concepts)) {
|
|
1288
|
+
return;
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
// Restore concepts to cache
|
|
1292
|
+
for (const concept of cached.concepts) {
|
|
1293
|
+
if (concept && concept.code) {
|
|
1294
|
+
this.sharedConceptCache.set(concept.code, concept);
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
this.loadedConceptCount = cached.concepts.length;
|
|
1299
|
+
this.customFingerprint = cached.fingerprint || null;
|
|
1300
|
+
this.isComplete = true;
|
|
1301
|
+
|
|
1302
|
+
if (this.meta?.codeSystem?.jsonObj) {
|
|
1303
|
+
this.meta.codeSystem.jsonObj.content = CodeSystemContentMode.Complete;
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
this.#applyConceptsToCodeSystemResource(this.meta?.codeSystem || null);
|
|
1307
|
+
|
|
1308
|
+
console.log(`[OCL] Loaded CodeSystem from cold cache: ${canonicalUrl} (${cached.concepts.length} concepts, fingerprint=${this.customFingerprint?.substring(0, 8)})`);
|
|
1309
|
+
} catch (error) {
|
|
1310
|
+
if (error.code !== 'ENOENT') {
|
|
1311
|
+
console.error(`[OCL] Failed to load cold cache for CodeSystem ${canonicalUrl}:`, error.message);
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
async #saveColdCache(concepts) {
|
|
1317
|
+
const canonicalUrl = this.system();
|
|
1318
|
+
const version = this.version();
|
|
1319
|
+
const cacheFilePath = getCacheFilePath(CACHE_CS_DIR, canonicalUrl, version);
|
|
1320
|
+
|
|
1321
|
+
try {
|
|
1322
|
+
await ensureCacheDirectories(CACHE_CS_DIR, CACHE_VS_DIR);
|
|
1323
|
+
|
|
1324
|
+
const fingerprint = computeCodeSystemFingerprint(concepts);
|
|
1325
|
+
const cacheData = {
|
|
1326
|
+
canonicalUrl,
|
|
1327
|
+
version,
|
|
1328
|
+
fingerprint,
|
|
1329
|
+
timestamp: new Date().toISOString(),
|
|
1330
|
+
conceptCount: concepts.length,
|
|
1331
|
+
concepts
|
|
1332
|
+
};
|
|
1333
|
+
|
|
1334
|
+
await fs.writeFile(cacheFilePath, JSON.stringify(cacheData, null, 2), 'utf-8');
|
|
1335
|
+
console.log(`[OCL] Saved CodeSystem to cold cache: ${canonicalUrl} (${concepts.length} concepts, fingerprint=${fingerprint?.substring(0, 8)})`);
|
|
1336
|
+
|
|
1337
|
+
return fingerprint;
|
|
1338
|
+
} catch (error) {
|
|
1339
|
+
console.error(`[OCL] Failed to save cold cache for CodeSystem ${canonicalUrl}:`, error.message);
|
|
1340
|
+
return null;
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
static scheduleBackgroundLoadByKey(system, version = null, reason = 'valueset-expansion') {
|
|
1345
|
+
const normalizedSystem = OCLSourceCodeSystemFactory.#normalizeSystem(system);
|
|
1346
|
+
const key = `${normalizedSystem}|${version || ''}`;
|
|
1347
|
+
const factory = OCLSourceCodeSystemFactory.#findFactory(normalizedSystem, version);
|
|
1348
|
+
if (!factory) {
|
|
1349
|
+
console.log(`[OCL] CodeSystem load not scheduled (factory unavailable): ${key}`);
|
|
1350
|
+
return false;
|
|
1351
|
+
}
|
|
1352
|
+
factory.scheduleBackgroundLoad(reason);
|
|
1353
|
+
return true;
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
static checksumForResource(system, version = null) {
|
|
1357
|
+
const normalizedSystem = OCLSourceCodeSystemFactory.#normalizeSystem(system);
|
|
1358
|
+
const factory = OCLSourceCodeSystemFactory.#findFactory(normalizedSystem, version);
|
|
1359
|
+
if (!factory) {
|
|
1360
|
+
return null;
|
|
1361
|
+
}
|
|
1362
|
+
return factory.currentChecksum();
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
static loadProgress() {
|
|
1366
|
+
let total = 0;
|
|
1367
|
+
let loaded = 0;
|
|
1368
|
+
|
|
1369
|
+
for (const factory of OCLSourceCodeSystemFactory.factoriesByKey.values()) {
|
|
1370
|
+
total += 1;
|
|
1371
|
+
if (factory && factory.isCompleteNow()) {
|
|
1372
|
+
loaded += 1;
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
const percentage = total > 0 ? (loaded / total) * 100 : 0;
|
|
1377
|
+
return { loaded, total, percentage };
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
defaultVersion() {
|
|
1381
|
+
return this.meta.version || null;
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
build(opContext, supplements) {
|
|
1385
|
+
this.#syncWarmStateWithChecksum();
|
|
1386
|
+
this.#applyConceptsToCodeSystemResource(this.meta?.codeSystem || null);
|
|
1387
|
+
this.recordUse();
|
|
1388
|
+
return new OCLSourceCodeSystemProvider(opContext, supplements, this.httpClient, this.meta, {
|
|
1389
|
+
conceptCache: this.sharedConceptCache,
|
|
1390
|
+
pageCache: this.sharedPageCache,
|
|
1391
|
+
pendingConceptRequests: this.sharedPendingConceptRequests,
|
|
1392
|
+
pendingPageRequests: this.sharedPendingPageRequests,
|
|
1393
|
+
scheduleBackgroundLoad: reason => this.scheduleBackgroundLoad(reason),
|
|
1394
|
+
isSystemComplete: () => this.isComplete,
|
|
1395
|
+
getTotalConceptCount: () => this.loadedConceptCount
|
|
1396
|
+
});
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
scheduleBackgroundLoad(reason = 'request') {
|
|
1400
|
+
this.#syncWarmStateWithChecksum();
|
|
1401
|
+
const cacheFilePath = getCacheFilePath(CACHE_CS_DIR, this.system(), this.version());
|
|
1402
|
+
const cacheAgeMs = getColdCacheAgeMs(cacheFilePath);
|
|
1403
|
+
|
|
1404
|
+
// If warm state is complete but cold cache is stale, force a refresh run.
|
|
1405
|
+
// This keeps warm data available while ensuring stale cold-cache files are replaced.
|
|
1406
|
+
if (this.isComplete) {
|
|
1407
|
+
if (cacheAgeMs == null || cacheAgeMs < COLD_CACHE_FRESHNESS_MS) {
|
|
1408
|
+
return;
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
this.isComplete = false;
|
|
1412
|
+
if (this.meta?.codeSystem?.jsonObj?.content === CodeSystemContentMode.Complete) {
|
|
1413
|
+
this.meta.codeSystem.jsonObj.content = CodeSystemContentMode.Fragment;
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
if (cacheAgeMs != null && cacheAgeMs < COLD_CACHE_FRESHNESS_MS) {
|
|
1418
|
+
console.log(`[OCL] Skipping warm-up for CodeSystem ${this.system()} (cold cache age: ${formatCacheAgeMinutes(cacheAgeMs)})`);
|
|
1419
|
+
return;
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
const key = this.#resourceKey();
|
|
1423
|
+
const jobKey = `cs:${key}`;
|
|
1424
|
+
|
|
1425
|
+
if (OCLBackgroundJobQueue.isQueuedOrRunning(jobKey)) {
|
|
1426
|
+
console.log(`[OCL] CodeSystem load already queued or running: ${key}`);
|
|
1427
|
+
return;
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
let queuedJobSize = null;
|
|
1431
|
+
console.log(`[OCL] CodeSystem load enqueued: ${key} (${reason})`);
|
|
1432
|
+
OCLBackgroundJobQueue.enqueue(
|
|
1433
|
+
jobKey,
|
|
1434
|
+
'CodeSystem load',
|
|
1435
|
+
async () => {
|
|
1436
|
+
await this.#runBackgroundLoad(key, queuedJobSize);
|
|
1437
|
+
},
|
|
1438
|
+
{
|
|
1439
|
+
jobId: this.system(),
|
|
1440
|
+
getProgress: () => this.#backgroundLoadProgressSnapshot(),
|
|
1441
|
+
resolveJobSize: async () => {
|
|
1442
|
+
queuedJobSize = await this.#fetchConceptCountFromHeaders();
|
|
1443
|
+
return queuedJobSize;
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
);
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
async #runBackgroundLoad(key, knownConceptCount = null) {
|
|
1450
|
+
console.log(`[OCL] CodeSystem load started: ${key}`);
|
|
1451
|
+
try {
|
|
1452
|
+
this.backgroundLoadProgress = { processed: 0, total: null };
|
|
1453
|
+
const resolvedTotal = Number.isFinite(knownConceptCount) && knownConceptCount >= 0
|
|
1454
|
+
? knownConceptCount
|
|
1455
|
+
: await this.#fetchConceptCountFromHeaders();
|
|
1456
|
+
this.backgroundLoadProgress.total = resolvedTotal;
|
|
1457
|
+
const count = await this.#loadAllConceptPages();
|
|
1458
|
+
this.loadedConceptCount = count;
|
|
1459
|
+
this.isComplete = true;
|
|
1460
|
+
this.loadedChecksum = this.meta?.checksum || null;
|
|
1461
|
+
this.backgroundLoadProgress = {
|
|
1462
|
+
processed: count,
|
|
1463
|
+
total: count > 0 ? count : this.backgroundLoadProgress.total
|
|
1464
|
+
};
|
|
1465
|
+
|
|
1466
|
+
if (this.meta?.codeSystem?.jsonObj) {
|
|
1467
|
+
this.meta.codeSystem.jsonObj.content = CodeSystemContentMode.Complete;
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
this.#applyConceptsToCodeSystemResource(this.meta?.codeSystem || null);
|
|
1471
|
+
|
|
1472
|
+
// Compute custom fingerprint and compare with cold cache
|
|
1473
|
+
const allConcepts = Array.from(this.sharedConceptCache.values());
|
|
1474
|
+
const newFingerprint = computeCodeSystemFingerprint(allConcepts);
|
|
1475
|
+
|
|
1476
|
+
if (this.customFingerprint && newFingerprint === this.customFingerprint) {
|
|
1477
|
+
console.log(`[OCL] CodeSystem fingerprint unchanged: ${key} (fingerprint=${newFingerprint?.substring(0, 8)})`);
|
|
1478
|
+
} else {
|
|
1479
|
+
if (this.customFingerprint) {
|
|
1480
|
+
console.log(`[OCL] CodeSystem fingerprint changed: ${key} (${this.customFingerprint?.substring(0, 8)} -> ${newFingerprint?.substring(0, 8)})`);
|
|
1481
|
+
console.log(`[OCL] Replacing cold cache with new hot cache: ${key}`);
|
|
1482
|
+
} else {
|
|
1483
|
+
console.log(`[OCL] Computed fingerprint for CodeSystem: ${key} (fingerprint=${newFingerprint?.substring(0, 8)})`);
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
// Save to cold cache
|
|
1487
|
+
const savedFingerprint = await this.#saveColdCache(allConcepts);
|
|
1488
|
+
if (savedFingerprint) {
|
|
1489
|
+
this.customFingerprint = savedFingerprint;
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
console.log(`[OCL] CodeSystem load completed, marked content=complete: ${key}`);
|
|
1494
|
+
const progress = OCLSourceCodeSystemFactory.loadProgress();
|
|
1495
|
+
console.log(`[OCL] CodeSystem load completed: ${this.system()}. Loaded ${progress.loaded}/${progress.total} CodeSystems (${progress.percentage.toFixed(2)}%)`);
|
|
1496
|
+
console.log(`[OCL] CodeSystem now available in cache: ${key} (${count} concepts)`);
|
|
1497
|
+
} catch (error) {
|
|
1498
|
+
console.error(`[OCL] CodeSystem background load failed: ${key}: ${error.message}`);
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
async #loadAllConceptPages() {
|
|
1503
|
+
if (!this.meta?.conceptsUrl) {
|
|
1504
|
+
this.loadedConceptCount = 0;
|
|
1505
|
+
this.backgroundLoadProgress = { processed: 0, total: 0 };
|
|
1506
|
+
return 0;
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
let page = 1;
|
|
1510
|
+
let total = 0;
|
|
1511
|
+
|
|
1512
|
+
// eslint-disable-next-line no-constant-condition
|
|
1513
|
+
while (true) {
|
|
1514
|
+
const pageData = await this.#fetchAndCacheConceptPage(page);
|
|
1515
|
+
const concepts = Array.isArray(pageData?.concepts) ? pageData.concepts : [];
|
|
1516
|
+
if (concepts.length === 0) {
|
|
1517
|
+
break;
|
|
1518
|
+
}
|
|
1519
|
+
total += concepts.length;
|
|
1520
|
+
this.backgroundLoadProgress.processed = total;
|
|
1521
|
+
if (concepts.length < CONCEPT_PAGE_SIZE) {
|
|
1522
|
+
break;
|
|
1523
|
+
}
|
|
1524
|
+
page += 1;
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
return total;
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
async #fetchAndCacheConceptPage(page) {
|
|
1531
|
+
const cacheKey = `${this.meta.conceptsUrl}|p=${page}|l=${CONCEPT_PAGE_SIZE}`;
|
|
1532
|
+
if (this.sharedPageCache.has(cacheKey)) {
|
|
1533
|
+
const cached = this.sharedPageCache.get(cacheKey);
|
|
1534
|
+
const concepts = Array.isArray(cached)
|
|
1535
|
+
? cached
|
|
1536
|
+
: Array.isArray(cached?.concepts)
|
|
1537
|
+
? cached.concepts
|
|
1538
|
+
: [];
|
|
1539
|
+
const reportedTotal = this.#extractTotalFromPayload(cached?.payload || null);
|
|
1540
|
+
return { concepts, reportedTotal };
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
if (this.sharedPendingPageRequests.has(cacheKey)) {
|
|
1544
|
+
return await this.sharedPendingPageRequests.get(cacheKey);
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
const pending = (async () => {
|
|
1548
|
+
let response;
|
|
1549
|
+
try {
|
|
1550
|
+
response = await this.httpClient.get(this.meta.conceptsUrl, { params: { page, limit: CONCEPT_PAGE_SIZE } });
|
|
1551
|
+
} catch (error) {
|
|
1552
|
+
if (error && error.response && error.response.status === 404) {
|
|
1553
|
+
this.sharedPageCache.set(cacheKey, []);
|
|
1554
|
+
return [];
|
|
1555
|
+
}
|
|
1556
|
+
throw error;
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
const payload = response.data;
|
|
1560
|
+
const items = Array.isArray(payload)
|
|
1561
|
+
? payload
|
|
1562
|
+
: Array.isArray(payload?.results)
|
|
1563
|
+
? payload.results
|
|
1564
|
+
: Array.isArray(payload?.items)
|
|
1565
|
+
? payload.items
|
|
1566
|
+
: Array.isArray(payload?.data)
|
|
1567
|
+
? payload.data
|
|
1568
|
+
: [];
|
|
1569
|
+
|
|
1570
|
+
const mapped = items
|
|
1571
|
+
.map(item => this.#toConceptContext(item))
|
|
1572
|
+
.filter(Boolean);
|
|
1573
|
+
|
|
1574
|
+
this.sharedPageCache.set(cacheKey, { concepts: mapped, payload });
|
|
1575
|
+
for (const concept of mapped) {
|
|
1576
|
+
if (concept && concept.code) {
|
|
1577
|
+
this.sharedConceptCache.set(concept.code, concept);
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
return {
|
|
1581
|
+
concepts: mapped,
|
|
1582
|
+
reportedTotal: this.#extractTotalFromPayload(payload)
|
|
1583
|
+
};
|
|
1584
|
+
})();
|
|
1585
|
+
|
|
1586
|
+
this.sharedPendingPageRequests.set(cacheKey, pending);
|
|
1587
|
+
try {
|
|
1588
|
+
return await pending;
|
|
1589
|
+
} finally {
|
|
1590
|
+
this.sharedPendingPageRequests.delete(cacheKey);
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
#syncWarmStateWithChecksum() {
|
|
1595
|
+
const checksum = this.meta?.checksum || null;
|
|
1596
|
+
if (this.loadedChecksum == null) {
|
|
1597
|
+
this.loadedChecksum = checksum;
|
|
1598
|
+
return;
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
if (checksum !== this.loadedChecksum) {
|
|
1602
|
+
this.isComplete = false;
|
|
1603
|
+
this.loadedConceptCount = -1;
|
|
1604
|
+
this.backgroundLoadProgress = { processed: 0, total: null };
|
|
1605
|
+
this.sharedConceptCache.clear();
|
|
1606
|
+
this.sharedPageCache.clear();
|
|
1607
|
+
this.loadedChecksum = checksum;
|
|
1608
|
+
this.materializedConceptList = null;
|
|
1609
|
+
this.materializedConceptCount = -1;
|
|
1610
|
+
if (this.meta?.codeSystem?.jsonObj) {
|
|
1611
|
+
this.meta.codeSystem.jsonObj.content = CodeSystemContentMode.NotPresent;
|
|
1612
|
+
delete this.meta.codeSystem.jsonObj.concept;
|
|
1613
|
+
}
|
|
1614
|
+
console.log(`[OCL] CodeSystem checksum changed, invalidated warm cache: ${this.#resourceKey()}`);
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
#applyConceptsToCodeSystemResource(codeSystem) {
|
|
1619
|
+
if (!codeSystem || typeof codeSystem !== 'object' || !codeSystem.jsonObj) {
|
|
1620
|
+
return;
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
if (this.isComplete !== true) {
|
|
1624
|
+
delete codeSystem.jsonObj.concept;
|
|
1625
|
+
return;
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
const concepts = Array.from(this.sharedConceptCache.values())
|
|
1629
|
+
.filter(concept => concept && concept.code);
|
|
1630
|
+
|
|
1631
|
+
if (!Array.isArray(this.materializedConceptList) || this.materializedConceptCount !== concepts.length) {
|
|
1632
|
+
this.materializedConceptList = concepts
|
|
1633
|
+
.sort((a, b) => String(a.code).localeCompare(String(b.code)))
|
|
1634
|
+
.map(concept => {
|
|
1635
|
+
const fhirConcept = { code: concept.code };
|
|
1636
|
+
|
|
1637
|
+
if (concept.display) {
|
|
1638
|
+
fhirConcept.display = concept.display;
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
if (concept.definition) {
|
|
1642
|
+
fhirConcept.definition = concept.definition;
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
if (Array.isArray(concept.designations) && concept.designations.length > 0) {
|
|
1646
|
+
const designations = concept.designations
|
|
1647
|
+
.filter(d => d && d.value)
|
|
1648
|
+
.map(d => ({
|
|
1649
|
+
language: d.language || undefined,
|
|
1650
|
+
value: d.value
|
|
1651
|
+
}));
|
|
1652
|
+
|
|
1653
|
+
if (designations.length > 0) {
|
|
1654
|
+
fhirConcept.designation = designations;
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
return fhirConcept;
|
|
1659
|
+
});
|
|
1660
|
+
this.materializedConceptCount = concepts.length;
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
codeSystem.jsonObj.concept = this.materializedConceptList;
|
|
1664
|
+
codeSystem.jsonObj.content = CodeSystemContentMode.Complete;
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
#backgroundLoadProgressSnapshot() {
|
|
1668
|
+
const processed = this.backgroundLoadProgress?.processed;
|
|
1669
|
+
const total = this.backgroundLoadProgress?.total;
|
|
1670
|
+
if (
|
|
1671
|
+
typeof processed === 'number' &&
|
|
1672
|
+
Number.isFinite(processed) &&
|
|
1673
|
+
typeof total === 'number' &&
|
|
1674
|
+
Number.isFinite(total) &&
|
|
1675
|
+
total > 0
|
|
1676
|
+
) {
|
|
1677
|
+
return { processed, total };
|
|
1678
|
+
}
|
|
1679
|
+
return null;
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
#extractTotalFromPayload(payload) {
|
|
1683
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
1684
|
+
return null;
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
const candidates = [
|
|
1688
|
+
payload.total,
|
|
1689
|
+
payload.total_count,
|
|
1690
|
+
payload.totalCount,
|
|
1691
|
+
payload.num_found,
|
|
1692
|
+
payload.numFound,
|
|
1693
|
+
payload.count
|
|
1694
|
+
];
|
|
1695
|
+
|
|
1696
|
+
for (const candidate of candidates) {
|
|
1697
|
+
if (typeof candidate === 'number' && Number.isFinite(candidate) && candidate >= 0) {
|
|
1698
|
+
return candidate;
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
return null;
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
async #fetchConceptCountFromHeaders() {
|
|
1706
|
+
if (!this.meta?.conceptsUrl) {
|
|
1707
|
+
return null;
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
try {
|
|
1711
|
+
const response = await this.httpClient.get(this.meta.conceptsUrl, {
|
|
1712
|
+
params: {
|
|
1713
|
+
limit: 1
|
|
1714
|
+
}
|
|
1715
|
+
});
|
|
1716
|
+
return this.#extractNumFoundFromHeaders(response?.headers);
|
|
1717
|
+
} catch (error) {
|
|
1718
|
+
return null;
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
#extractNumFoundFromHeaders(headers) {
|
|
1723
|
+
if (!headers || typeof headers !== 'object') {
|
|
1724
|
+
return null;
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
const raw = headers.num_found ?? headers['num-found'] ?? headers.Num_Found ?? null;
|
|
1728
|
+
const parsed = typeof raw === 'number' ? raw : Number.parseInt(raw, 10);
|
|
1729
|
+
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
1730
|
+
return null;
|
|
1731
|
+
}
|
|
1732
|
+
return parsed;
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
#resourceKey() {
|
|
1736
|
+
const normalizedSystem = OCLSourceCodeSystemFactory.#normalizeSystem(this.system());
|
|
1737
|
+
return `${normalizedSystem}|${this.version() || ''}`;
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
currentChecksum() {
|
|
1741
|
+
this.#syncWarmStateWithChecksum();
|
|
1742
|
+
return this.meta?.checksum || this.loadedChecksum || null;
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
isCompleteNow() {
|
|
1746
|
+
this.#syncWarmStateWithChecksum();
|
|
1747
|
+
return this.isComplete === true;
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
#toConceptContext(concept) {
|
|
1751
|
+
return toConceptContext(concept);
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
system() {
|
|
1755
|
+
return normalizeCanonicalSystem(this.meta.canonicalUrl);
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
name() {
|
|
1759
|
+
return this.meta.name || this.meta.shortCode || this.meta.id || this.system();
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
version() {
|
|
1763
|
+
return this.meta.version || null;
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
id() {
|
|
1767
|
+
return this.meta.id || this.meta.shortCode || this.system();
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
iteratable() {
|
|
1771
|
+
return true;
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
module.exports = {
|
|
1776
|
+
OCLCodeSystemProvider,
|
|
1777
|
+
OCLSourceCodeSystemFactory,
|
|
1778
|
+
OCLBackgroundJobQueue
|
|
1779
|
+
};
|