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
@@ -0,0 +1,1848 @@
1
+ const fs = require('fs/promises');
2
+ const crypto = require('crypto');
3
+ const path = require('path');
4
+ const { AbstractValueSetProvider } = require('../vs/vs-api');
5
+ const { VersionUtilities } = require('../../library/version-utilities');
6
+ const ValueSet = require('../library/valueset');
7
+ const { SearchFilterText } = require('../library/designations');
8
+ const { TxParameters } = require('../params');
9
+ const { OCLSourceCodeSystemFactory, OCLBackgroundJobQueue } = require('./cs-ocl');
10
+ const { PAGE_SIZE, CONCEPT_PAGE_SIZE, FILTERED_CONCEPT_PAGE_SIZE, COLD_CACHE_FRESHNESS_MS } = require('./shared/constants');
11
+ const { createOclHttpClient } = require('./http/client');
12
+ const { CACHE_VS_DIR, getCacheFilePath } = require('./cache/cache-paths');
13
+ const { ensureCacheDirectories, getColdCacheAgeMs, formatCacheAgeMinutes } = require('./cache/cache-utils');
14
+ const { computeValueSetExpansionFingerprint } = require('./fingerprint/fingerprint');
15
+ const { ensureTxParametersHashIncludesFilter, patchValueSetExpandWholeSystemForOcl } = require('./shared/patches');
16
+
17
+ ensureTxParametersHashIncludesFilter(TxParameters);
18
+ patchValueSetExpandWholeSystemForOcl();
19
+
20
+ function normalizeCanonicalSystem(system) {
21
+ if (typeof system !== 'string') {
22
+ return system;
23
+ }
24
+
25
+ const trimmed = system.trim();
26
+ if (!trimmed) {
27
+ return trimmed;
28
+ }
29
+
30
+ // Treat canonical URLs with and without trailing slash as equivalent.
31
+ return trimmed.replace(/\/+$/, '');
32
+ }
33
+
34
+ class OCLValueSetProvider extends AbstractValueSetProvider {
35
+ constructor(config = {}) {
36
+ super();
37
+ const options = typeof config === 'string' ? { baseUrl: config } : (config || {});
38
+
39
+ this.org = options.org || null;
40
+ const http = createOclHttpClient(options);
41
+ this.baseUrl = http.baseUrl;
42
+ this.httpClient = http.client;
43
+
44
+ this.valueSetMap = new Map();
45
+ this._idMap = new Map();
46
+ this.collectionMeta = new Map();
47
+ this.sourceCanonicalCache = new Map();
48
+ this.collectionConceptPageCache = new Map();
49
+ this.pendingCollectionConceptPageRequests = new Map();
50
+ this.collectionSourcesCache = new Map();
51
+ this.pendingCollectionSourcesRequests = new Map();
52
+ this.pendingSourceCanonicalRequests = new Map();
53
+ this.collectionByCanonicalCache = new Map();
54
+ this.pendingCollectionByCanonicalRequests = new Map();
55
+ this._composePromises = new Map();
56
+ this.backgroundExpansionCache = new Map();
57
+ this.backgroundExpansionProgress = new Map();
58
+ this.valueSetFingerprints = new Map();
59
+ this._initialized = false;
60
+ this._initializePromise = null;
61
+ this.sourcePackageCode = this.org
62
+ ? `ocl:${this.baseUrl}|org=${this.org}`
63
+ : `ocl:${this.baseUrl}`;
64
+ }
65
+
66
+ async #loadColdCacheForValueSets() {
67
+ try {
68
+ const files = await fs.readdir(CACHE_VS_DIR);
69
+ let loadedCount = 0;
70
+
71
+ for (const file of files) {
72
+ if (!file.endsWith('.json')) {
73
+ continue;
74
+ }
75
+
76
+ try {
77
+ const filePath = path.join(CACHE_VS_DIR, file);
78
+ const data = await fs.readFile(filePath, 'utf-8');
79
+ const cached = JSON.parse(data);
80
+
81
+ if (!cached || !cached.canonicalUrl || !cached.expansion) {
82
+ continue;
83
+ }
84
+
85
+ const paramsKey = cached.paramsKey || 'default';
86
+ const cacheKey = this.#expansionCacheKey(
87
+ { url: cached.canonicalUrl, version: cached.version || null },
88
+ paramsKey
89
+ );
90
+ const createdAt = cached.timestamp ? new Date(cached.timestamp).getTime() : null;
91
+ this.backgroundExpansionCache.set(cacheKey, {
92
+ expansion: cached.expansion,
93
+ metadataSignature: cached.metadataSignature || null,
94
+ dependencyChecksums: cached.dependencyChecksums || {},
95
+ createdAt: Number.isFinite(createdAt) ? createdAt : null
96
+ });
97
+
98
+ this.valueSetFingerprints.set(cacheKey, cached.fingerprint || null);
99
+ loadedCount++;
100
+ console.log(`[OCL-ValueSet] Loaded ValueSet from cold cache: ${cached.canonicalUrl}`);
101
+ } catch (error) {
102
+ console.error(`[OCL-ValueSet] Failed to load cold cache file ${file}:`, error.message);
103
+ }
104
+ }
105
+
106
+ if (loadedCount > 0) {
107
+ console.log(`[OCL-ValueSet] Loaded ${loadedCount} ValueSet expansions from cold cache`);
108
+ }
109
+ } catch (error) {
110
+ if (error.code !== 'ENOENT') {
111
+ console.error('[OCL-ValueSet] Failed to load cold cache:', error.message);
112
+ }
113
+ }
114
+ }
115
+
116
+ async #saveColdCacheForValueSet(vs, expansion, metadataSignature, dependencyChecksums, paramsKey = 'default') {
117
+ const canonicalUrl = vs?.url;
118
+ const version = vs?.version || null;
119
+ if (!canonicalUrl || !expansion) {
120
+ return null;
121
+ }
122
+
123
+ const cacheFilePath = getCacheFilePath(CACHE_VS_DIR, canonicalUrl, version, paramsKey);
124
+
125
+ try {
126
+ await ensureCacheDirectories(CACHE_VS_DIR);
127
+
128
+ const fingerprint = computeValueSetExpansionFingerprint(expansion);
129
+ const cacheData = {
130
+ canonicalUrl,
131
+ version,
132
+ paramsKey,
133
+ fingerprint,
134
+ timestamp: new Date().toISOString(),
135
+ conceptCount: expansion.contains?.length || 0,
136
+ expansion,
137
+ metadataSignature,
138
+ dependencyChecksums
139
+ };
140
+
141
+ await fs.writeFile(cacheFilePath, JSON.stringify(cacheData, null, 2), 'utf-8');
142
+ console.log(`[OCL-ValueSet] Saved ValueSet expansion to cold cache: ${canonicalUrl} (${expansion.contains?.length || 0} concepts, fingerprint=${fingerprint?.substring(0, 8)})`);
143
+
144
+ return fingerprint;
145
+ } catch (error) {
146
+ console.error(`[OCL-ValueSet] Failed to save cold cache for ValueSet ${canonicalUrl}:`, error.message);
147
+ return null;
148
+ }
149
+ }
150
+
151
+ sourcePackage() {
152
+ return this.sourcePackageCode;
153
+ }
154
+
155
+ async initialize() {
156
+ if (this._initialized) {
157
+ return;
158
+ }
159
+
160
+ if (this._initializePromise) {
161
+ await this._initializePromise;
162
+ return;
163
+ }
164
+
165
+ this._initializePromise = (async () => {
166
+ try {
167
+ // Load cold cache first
168
+ await this.#loadColdCacheForValueSets();
169
+
170
+ const collections = await this.#fetchCollectionsForDiscovery();
171
+ console.log(`[OCL-ValueSet] Fetched ${collections.length} collections`);
172
+
173
+ for (const collection of collections) {
174
+ const valueSet = this.#toValueSet(collection);
175
+ if (!valueSet) {
176
+ continue;
177
+ }
178
+ this.#indexValueSet(valueSet);
179
+ }
180
+
181
+ console.log(`[OCL-ValueSet] Loaded ${this.valueSetMap.size} value sets`);
182
+ this._initialized = true;
183
+ } catch (error) {
184
+ console.error(`[OCL-ValueSet] Initialization failed:`, error.message);
185
+ if (error.response) {
186
+ console.error(`[OCL-ValueSet] HTTP ${error.response.status}: ${error.response.statusText}`);
187
+ }
188
+ throw error;
189
+ }
190
+ })();
191
+
192
+ try {
193
+ await this._initializePromise;
194
+ } finally {
195
+ this._initializePromise = null;
196
+ }
197
+ }
198
+
199
+ assignIds(ids) {
200
+ if (!this.spaceId) {
201
+ return;
202
+ }
203
+
204
+ const unique = new Set(this.valueSetMap.values());
205
+ this._idMap.clear();
206
+
207
+ for (const vs of unique) {
208
+ if (!vs.id.startsWith(`${this.spaceId}-`)) {
209
+ const nextId = `${this.spaceId}-${vs.id}`;
210
+ vs.id = nextId;
211
+ vs.jsonObj.id = nextId;
212
+ }
213
+ this._idMap.set(vs.id, vs);
214
+ ids.add(`ValueSet/${vs.id}`);
215
+ }
216
+ }
217
+
218
+ async fetchValueSet(url, version) {
219
+ this._validateFetchParams(url, version);
220
+
221
+ let key = `${url}|${version}`;
222
+ if (this.valueSetMap.has(key)) {
223
+ const vs = this.valueSetMap.get(key);
224
+ await this.#ensureComposeIncludes(vs);
225
+ this.#clearInlineExpansion(vs);
226
+ this.#scheduleBackgroundExpansion(vs, { reason: 'fetch-valueset' });
227
+ return vs;
228
+ }
229
+
230
+ if (version && VersionUtilities.isSemVer(version)) {
231
+ const majorMinor = VersionUtilities.getMajMin(version);
232
+ if (majorMinor) {
233
+ key = `${url}|${majorMinor}`;
234
+ if (this.valueSetMap.has(key)) {
235
+ const vs = this.valueSetMap.get(key);
236
+ await this.#ensureComposeIncludes(vs);
237
+ this.#clearInlineExpansion(vs);
238
+ this.#scheduleBackgroundExpansion(vs, { reason: 'fetch-valueset-mm' });
239
+ return vs;
240
+ }
241
+ }
242
+ }
243
+
244
+ if (this.valueSetMap.has(url)) {
245
+ const vs = this.valueSetMap.get(url);
246
+ await this.#ensureComposeIncludes(vs);
247
+ this.#clearInlineExpansion(vs);
248
+ this.#scheduleBackgroundExpansion(vs, { reason: 'fetch-valueset-url' });
249
+ return vs;
250
+ }
251
+
252
+ const resolved = await this.#resolveValueSetByCanonical(url, version);
253
+ if (resolved) {
254
+ await this.#ensureComposeIncludes(resolved);
255
+ this.#clearInlineExpansion(resolved);
256
+ this.#scheduleBackgroundExpansion(resolved, { reason: 'fetch-valueset-resolved' });
257
+ return resolved;
258
+ }
259
+
260
+ await this.initialize();
261
+
262
+ key = `${url}|${version}`;
263
+ if (this.valueSetMap.has(key)) {
264
+ const vs = this.valueSetMap.get(key);
265
+ await this.#ensureComposeIncludes(vs);
266
+ this.#clearInlineExpansion(vs);
267
+ this.#scheduleBackgroundExpansion(vs, { reason: 'fetch-valueset-init' });
268
+ return vs;
269
+ }
270
+
271
+ if (version && VersionUtilities.isSemVer(version)) {
272
+ const majorMinor = VersionUtilities.getMajMin(version);
273
+ if (majorMinor) {
274
+ key = `${url}|${majorMinor}`;
275
+ if (this.valueSetMap.has(key)) {
276
+ const vs = this.valueSetMap.get(key);
277
+ await this.#ensureComposeIncludes(vs);
278
+ this.#clearInlineExpansion(vs);
279
+ this.#scheduleBackgroundExpansion(vs, { reason: 'fetch-valueset-init-mm' });
280
+ return vs;
281
+ }
282
+ }
283
+ }
284
+
285
+ if (this.valueSetMap.has(url)) {
286
+ const vs = this.valueSetMap.get(url);
287
+ await this.#ensureComposeIncludes(vs);
288
+ this.#clearInlineExpansion(vs);
289
+ this.#scheduleBackgroundExpansion(vs, { reason: 'fetch-valueset-init-url' });
290
+ return vs;
291
+ }
292
+
293
+ return null;
294
+ }
295
+
296
+ async fetchValueSetById(id) {
297
+ const local = this.#getLocalValueSetById(id);
298
+ if (local) {
299
+ await this.#ensureComposeIncludes(local);
300
+ this.#clearInlineExpansion(local);
301
+ this.#scheduleBackgroundExpansion(local, { reason: 'fetch-valueset-by-id' });
302
+ return local;
303
+ }
304
+
305
+ await this.initialize();
306
+
307
+ const vs = this.#getLocalValueSetById(id);
308
+ await this.#ensureComposeIncludes(vs);
309
+ this.#clearInlineExpansion(vs);
310
+ this.#scheduleBackgroundExpansion(vs, { reason: 'fetch-valueset-by-id-init' });
311
+ return vs;
312
+ }
313
+
314
+ #clearInlineExpansion(vs) {
315
+ if (!vs || !vs.jsonObj || !vs.jsonObj.expansion) {
316
+ return;
317
+ }
318
+ delete vs.jsonObj.expansion;
319
+ }
320
+
321
+ #getLocalValueSetById(id) {
322
+ if (this._idMap.has(id)) {
323
+ return this._idMap.get(id);
324
+ }
325
+
326
+ if (this.spaceId && id.startsWith(`${this.spaceId}-`)) {
327
+ const unprefixed = id.substring(this.spaceId.length + 1);
328
+ return this._idMap.get(id) || this._idMap.get(unprefixed) || this.valueSetMap.get(unprefixed) || null;
329
+ }
330
+
331
+ return this._idMap.get(id) || this.valueSetMap.get(id) || null;
332
+ }
333
+
334
+ // eslint-disable-next-line no-unused-vars
335
+ async searchValueSets(searchParams, _elements) {
336
+ await this.initialize();
337
+ this._validateSearchParams(searchParams);
338
+
339
+ const params = Object.fromEntries(searchParams.map(({ name, value }) => [name, String(value).toLowerCase()]));
340
+ const values = Array.from(new Set(this.valueSetMap.values()));
341
+
342
+ if (Object.keys(params).length === 0) {
343
+ return values;
344
+ }
345
+
346
+ return values.filter(vs => this.#matches(vs.jsonObj, params));
347
+ }
348
+
349
+ vsCount() {
350
+ return new Set(this.valueSetMap.values()).size;
351
+ }
352
+
353
+ async listAllValueSets() {
354
+ await this.initialize();
355
+ const urls = new Set();
356
+ for (const vs of this.valueSetMap.values()) {
357
+ if (vs && vs.url) {
358
+ urls.add(vs.url);
359
+ }
360
+ }
361
+ return Array.from(urls);
362
+ }
363
+
364
+ async close() {
365
+ }
366
+
367
+ #indexValueSet(vs) {
368
+ const existing = this.valueSetMap.get(vs.url)
369
+ || (vs.version ? this.valueSetMap.get(`${vs.url}|${vs.version}`) : null)
370
+ || this._idMap.get(vs.id)
371
+ || null;
372
+
373
+ // Preserve hydrated cold-cache expansions on first index; invalidate only on replacement.
374
+ if (existing && existing !== vs) {
375
+ this.#invalidateExpansionCache(vs);
376
+ }
377
+
378
+ this.valueSetMap.set(vs.url, vs);
379
+ if (vs.version) {
380
+ this.valueSetMap.set(`${vs.url}|${vs.version}`, vs);
381
+ }
382
+ this.valueSetMap.set(vs.id, vs);
383
+ this._idMap.set(vs.id, vs);
384
+ }
385
+
386
+ #toValueSet(collection) {
387
+ if (!collection || typeof collection !== 'object') {
388
+ return null;
389
+ }
390
+
391
+ const canonicalUrl = collection.canonical_url || collection.canonicalUrl || collection.url;
392
+ const id = collection.id;
393
+ if (!canonicalUrl || !id) {
394
+ return null;
395
+ }
396
+
397
+ const preferredSource = normalizeCanonicalSystem(collection.preferred_source || collection.preferredSource || null);
398
+ const json = {
399
+ resourceType: 'ValueSet',
400
+ id,
401
+ url: canonicalUrl,
402
+ version: collection.version || null,
403
+ name: collection.name || id,
404
+ title: collection.full_name || collection.fullName || collection.name || id,
405
+ status: 'active',
406
+ experimental: collection.experimental === true,
407
+ immutable: collection.immutable === true,
408
+ description: collection.description || null,
409
+ publisher: collection.publisher || collection.owner || null,
410
+ language: collection.default_locale || collection.defaultLocale || null
411
+ };
412
+
413
+ const lastUpdated = this.#toIsoDate(collection.updated_on || collection.updatedOn || collection.updated_at || collection.updatedAt);
414
+ if (lastUpdated) {
415
+ json.meta = { lastUpdated };
416
+ }
417
+
418
+ if (preferredSource) {
419
+ json.compose = {
420
+ include: [{ system: preferredSource }]
421
+ };
422
+ }
423
+
424
+ const conceptsUrl = this.#normalizePath(
425
+ collection.concepts_url || collection.conceptsUrl || this.#buildCollectionConceptsPath(collection)
426
+ );
427
+ const expansionUrl = this.#normalizePath(
428
+ collection.expansion_url || collection.expansionUrl || this.#buildCollectionExpansionPath(collection)
429
+ );
430
+
431
+ const meta = {
432
+ collectionId: collection.id || collection.short_code || collection.shortCode || null,
433
+ conceptsUrl,
434
+ expansionUrl,
435
+ preferredSource,
436
+ owner: collection.owner || null,
437
+ ownerType: collection.owner_type || collection.ownerType || null
438
+ };
439
+
440
+ this.#storeCollectionMeta(id, canonicalUrl, meta);
441
+
442
+ const valueSet = new ValueSet(json, 'R5');
443
+ this.#attachOclHelpers(valueSet, meta);
444
+ return valueSet;
445
+ }
446
+
447
+ #attachOclHelpers(valueSet, meta) {
448
+ if (!valueSet || !meta) {
449
+ return;
450
+ }
451
+
452
+ valueSet.oclMeta = meta;
453
+ valueSet.oclFetchConcepts = async ({ count, offset, activeOnly, filter, languageCodes }) => {
454
+ return this.#fetchCollectionConcepts(meta, {
455
+ count,
456
+ offset,
457
+ activeOnly,
458
+ filter: typeof filter === 'string' ? filter : null,
459
+ languageCodes: Array.isArray(languageCodes) ? languageCodes : [],
460
+ fallbackSystem: meta.preferredSource || valueSet.url
461
+ });
462
+ };
463
+ }
464
+
465
+ #storeCollectionMeta(id, url, meta) {
466
+ if (!meta || (!meta.conceptsUrl && !meta.expansionUrl)) {
467
+ return;
468
+ }
469
+ if (id) {
470
+ this.collectionMeta.set(id, meta);
471
+ }
472
+ if (url) {
473
+ this.collectionMeta.set(url, meta);
474
+ }
475
+ }
476
+
477
+ #normalizePath(pathValue) {
478
+ if (!pathValue) {
479
+ return null;
480
+ }
481
+ if (typeof pathValue !== 'string') {
482
+ return null;
483
+ }
484
+ if (pathValue.startsWith('http://') || pathValue.startsWith('https://')) {
485
+ return pathValue;
486
+ }
487
+ return `${this.baseUrl}${pathValue.startsWith('/') ? '' : '/'}${pathValue}`;
488
+ }
489
+
490
+ #buildCollectionConceptsPath(collection) {
491
+ if (!collection || typeof collection !== 'object') {
492
+ return null;
493
+ }
494
+ const owner = collection.owner || null;
495
+ const ownerType = collection.owner_type || collection.ownerType || null;
496
+ const id = collection.id || collection.short_code || collection.shortCode || null;
497
+ if (!owner || !id || ownerType !== 'Organization') {
498
+ return null;
499
+ }
500
+ return `/orgs/${encodeURIComponent(owner)}/collections/${encodeURIComponent(id)}/concepts/`;
501
+ }
502
+
503
+ #buildCollectionExpansionPath(collection) {
504
+ if (!collection || typeof collection !== 'object') {
505
+ return null;
506
+ }
507
+ const owner = collection.owner || null;
508
+ const ownerType = collection.owner_type || collection.ownerType || null;
509
+ const id = collection.id || collection.short_code || collection.shortCode || null;
510
+ if (!owner || !id || ownerType !== 'Organization') {
511
+ return null;
512
+ }
513
+ return `/orgs/${encodeURIComponent(owner)}/collections/${encodeURIComponent(id)}/HEAD/expansions/autoexpand-HEAD/`;
514
+ }
515
+
516
+ #getCollectionMeta(vs) {
517
+ if (!vs) {
518
+ return null;
519
+ }
520
+ return this.collectionMeta.get(vs.id) || this.collectionMeta.get(vs.url) || null;
521
+ }
522
+
523
+ async #ensureComposeIncludes(vs) {
524
+ if (!vs || !vs.jsonObj) {
525
+ return;
526
+ }
527
+
528
+ const meta = this.#getCollectionMeta(vs);
529
+
530
+ const composeKey = vs.id || vs.url;
531
+ if (this._composePromises.has(composeKey)) {
532
+ await this._composePromises.get(composeKey);
533
+ return;
534
+ }
535
+
536
+ const promise = (async () => {
537
+ const existingInclude = Array.isArray(vs?.jsonObj?.compose?.include)
538
+ ? vs.jsonObj.compose.include
539
+ : [];
540
+
541
+ // Always normalize existing compose entries first because discovery metadata
542
+ // can carry non-canonical preferred_source values.
543
+ const include = this.#normalizeComposeIncludes(existingInclude);
544
+
545
+ // Reconcile with collection-resolved sources whenever available so $expand
546
+ // and direct CodeSystem lookups share the same canonical registry keys.
547
+ if (meta && (meta.conceptsUrl || meta.expansionUrl)) {
548
+ const sources = await this.#fetchCollectionSources(meta);
549
+ if (Array.isArray(sources) && sources.length > 0) {
550
+ include.push(...this.#normalizeComposeIncludes(sources));
551
+ }
552
+ }
553
+
554
+ // Preferred source is a fallback only when no resolvable include was found.
555
+ if (include.length === 0 && meta?.preferredSource) {
556
+ include.push(...this.#normalizeComposeIncludes([{ system: meta.preferredSource }]));
557
+ }
558
+
559
+ const deduped = this.#dedupeComposeIncludes(include);
560
+ if (deduped.length > 0) {
561
+ vs.jsonObj.compose = { include: deduped };
562
+ }
563
+ })();
564
+
565
+ this._composePromises.set(composeKey, promise);
566
+ try {
567
+ await promise;
568
+ } finally {
569
+ this._composePromises.delete(composeKey);
570
+ }
571
+ }
572
+
573
+ #normalizeComposeIncludes(includeEntries) {
574
+ if (!Array.isArray(includeEntries) || includeEntries.length === 0) {
575
+ return [];
576
+ }
577
+
578
+ const normalized = [];
579
+ for (const entry of includeEntries) {
580
+ if (!entry || typeof entry !== 'object') {
581
+ continue;
582
+ }
583
+
584
+ const system = normalizeCanonicalSystem(entry.system);
585
+ if (!system) {
586
+ continue;
587
+ }
588
+
589
+ let version = entry.version || null;
590
+ const hasAnyFactory = OCLSourceCodeSystemFactory.hasFactory(system, null);
591
+ const hasExactFactory = OCLSourceCodeSystemFactory.hasExactFactory(system, version);
592
+
593
+ // If include.version does not match the registered OCL factory key,
594
+ // omit it so Provider lookup can reuse the already loaded canonical factory.
595
+ if (version && hasAnyFactory && !hasExactFactory) {
596
+ version = null;
597
+ }
598
+
599
+ normalized.push({
600
+ system,
601
+ version: version || undefined
602
+ });
603
+ }
604
+
605
+ return normalized;
606
+ }
607
+
608
+ #dedupeComposeIncludes(includeEntries) {
609
+ const deduped = [];
610
+ const seen = new Set();
611
+
612
+ for (const include of includeEntries || []) {
613
+ const system = normalizeCanonicalSystem(include?.system);
614
+ if (!system) {
615
+ continue;
616
+ }
617
+
618
+ const version = include?.version || '';
619
+ const key = `${system}|${version}`;
620
+ if (seen.has(key)) {
621
+ continue;
622
+ }
623
+
624
+ seen.add(key);
625
+ deduped.push({
626
+ system,
627
+ version: version || undefined
628
+ });
629
+ }
630
+
631
+ return deduped;
632
+ }
633
+
634
+ async #fetchCollectionSources(meta) {
635
+ const sourcesCacheKey = `${meta.owner || ''}|${meta.collectionId || ''}|${meta.conceptsUrl || ''}|${meta.expansionUrl || ''}`;
636
+ if (this.collectionSourcesCache.has(sourcesCacheKey)) {
637
+ return this.collectionSourcesCache.get(sourcesCacheKey);
638
+ }
639
+ if (this.pendingCollectionSourcesRequests.has(sourcesCacheKey)) {
640
+ return this.pendingCollectionSourcesRequests.get(sourcesCacheKey);
641
+ }
642
+
643
+ const pending = (async () => {
644
+ const sources = [];
645
+ const seen = new Set();
646
+
647
+ if (meta.expansionUrl) {
648
+ try {
649
+ const response = await this.httpClient.get(meta.expansionUrl);
650
+ const resolved = Array.isArray(response.data?.resolved_source_versions)
651
+ ? response.data.resolved_source_versions
652
+ : [];
653
+
654
+ for (const entry of resolved) {
655
+ const system = entry.canonical_url || entry.canonicalUrl || null;
656
+ const owner = entry.owner || meta.owner || null;
657
+ const shortCode = entry.short_code || entry.shortCode || entry.id || null;
658
+ const version = entry.version || null;
659
+
660
+ const systemUrl = normalizeCanonicalSystem(system || (owner && shortCode ? await this.#getSourceCanonicalUrl(owner, shortCode) : null));
661
+ if (systemUrl && !seen.has(systemUrl)) {
662
+ seen.add(systemUrl);
663
+ sources.push({ system: systemUrl, version });
664
+ }
665
+ }
666
+ } catch (error) {
667
+ // fall through to concepts listing
668
+ }
669
+ }
670
+
671
+ if (sources.length > 0) {
672
+ return sources;
673
+ }
674
+
675
+ if (!meta.conceptsUrl) {
676
+ return sources;
677
+ }
678
+
679
+ const sourceKeys = await this.#fetchCollectionSourceKeys(meta.conceptsUrl, meta.owner || null);
680
+ for (const { owner, source } of sourceKeys) {
681
+ const systemUrl = normalizeCanonicalSystem(await this.#getSourceCanonicalUrl(owner, source));
682
+ if (systemUrl && !seen.has(systemUrl)) {
683
+ seen.add(systemUrl);
684
+ sources.push({ system: systemUrl });
685
+ }
686
+ }
687
+
688
+ if (sources.length === 0 && meta.preferredSource) {
689
+ const preferredSource = normalizeCanonicalSystem(meta.preferredSource);
690
+ if (preferredSource) {
691
+ sources.push({ system: preferredSource });
692
+ }
693
+ }
694
+
695
+ this.collectionSourcesCache.set(sourcesCacheKey, sources);
696
+ return sources;
697
+ })();
698
+
699
+ this.pendingCollectionSourcesRequests.set(sourcesCacheKey, pending);
700
+ try {
701
+ return await pending;
702
+ } finally {
703
+ this.pendingCollectionSourcesRequests.delete(sourcesCacheKey);
704
+ }
705
+ }
706
+
707
+ async #fetchCollectionConcepts(meta, options) {
708
+ if (!meta || !meta.conceptsUrl) {
709
+ return { contains: [], total: 0 };
710
+ }
711
+
712
+ const count = Number.isInteger(options?.count) ? options.count : CONCEPT_PAGE_SIZE;
713
+ const offset = Number.isInteger(options?.offset) ? options.offset : 0;
714
+ const activeOnly = options?.activeOnly === true;
715
+ const filter = this.#normalizeFilter(options?.filter);
716
+ const filterMatcher = filter ? new SearchFilterText(filter) : null;
717
+ const remoteQuery = this.#buildRemoteQuery(filter);
718
+ const fallbackSystem = options?.fallbackSystem || null;
719
+ const preferredLanguageCodes = this.#normalizeLanguageCodes(options?.languageCodes);
720
+ const effectiveLanguageCodes = preferredLanguageCodes.length > 0 ? preferredLanguageCodes : ['en'];
721
+
722
+ if (count <= 0) {
723
+ return { contains: [], total: 0 };
724
+ }
725
+
726
+ const hasFilter = !!filter;
727
+ const limit = hasFilter
728
+ ? Math.min(FILTERED_CONCEPT_PAGE_SIZE, CONCEPT_PAGE_SIZE)
729
+ : CONCEPT_PAGE_SIZE;
730
+ let page = Math.floor(Math.max(0, offset) / limit) + 1;
731
+ let skip = Math.max(0, offset) % limit;
732
+ let remaining = count;
733
+ const contains = [];
734
+ let reportedTotal = null;
735
+
736
+ while (remaining > 0) {
737
+ const pageData = await this.#fetchConceptPage(meta.conceptsUrl, page, limit, remoteQuery);
738
+ const pageItems = Array.isArray(pageData?.items) ? pageData.items : [];
739
+ if (
740
+ reportedTotal == null &&
741
+ typeof pageData?.reportedTotal === 'number' &&
742
+ Number.isFinite(pageData.reportedTotal) &&
743
+ pageData.reportedTotal >= 0
744
+ ) {
745
+ reportedTotal = pageData.reportedTotal;
746
+ }
747
+
748
+ if (!pageItems || pageItems.length === 0) {
749
+ break;
750
+ }
751
+
752
+ const slice = pageItems.slice(skip);
753
+ skip = 0;
754
+
755
+ for (const concept of slice) {
756
+ if (remaining <= 0) {
757
+ break;
758
+ }
759
+ if (activeOnly && concept.retired === true) {
760
+ continue;
761
+ }
762
+
763
+ const localizedNames = this.#extractLocalizedNames(concept, effectiveLanguageCodes);
764
+ const localizedDefinitions = this.#extractLocalizedDefinitions(concept, effectiveLanguageCodes);
765
+
766
+ const display = localizedNames.display || concept.display_name || concept.display || concept.name || null;
767
+ const definition = localizedDefinitions.definition || concept.definition || concept.description || concept.concept_class || null;
768
+ const code = concept.code || concept.id || null;
769
+ const searchableText = [
770
+ code,
771
+ display,
772
+ definition,
773
+ ...localizedNames.designation.map(d => d.value),
774
+ ...localizedDefinitions.definitions.map(d => d.value)
775
+ ].filter(Boolean).join(' ');
776
+ if (!this.#conceptMatchesFilter(searchableText, code, display, definition, filter, filterMatcher)) {
777
+ continue;
778
+ }
779
+
780
+ if (!code) {
781
+ continue;
782
+ }
783
+
784
+ const owner = concept.owner || meta.owner || null;
785
+ const source = concept.source || null;
786
+ const conceptCanonical = concept.source_canonical_url || concept.sourceCanonicalUrl || null;
787
+ const system = conceptCanonical || (owner && source
788
+ ? await this.#getSourceCanonicalUrl(owner, source)
789
+ : fallbackSystem);
790
+
791
+ contains.push({
792
+ system: system || fallbackSystem,
793
+ code,
794
+ display,
795
+ definition: definition || undefined,
796
+ designation: localizedNames.designation,
797
+ definitions: localizedDefinitions.definitions,
798
+ inactive: concept.retired === true ? true : undefined
799
+ });
800
+ remaining -= 1;
801
+ }
802
+
803
+ if (pageItems.length < limit) {
804
+ break;
805
+ }
806
+
807
+ page += 1;
808
+ }
809
+
810
+ return { contains, total: contains.length, reportedTotal };
811
+ }
812
+
813
+ async #resolveValueSetByCanonical(url, version) {
814
+ const canonicalUrl = typeof url === 'string' ? url.trim() : '';
815
+ if (!canonicalUrl) {
816
+ return null;
817
+ }
818
+
819
+ const collection = await this.#findCollectionByCanonical(canonicalUrl, version);
820
+ if (!collection) {
821
+ return null;
822
+ }
823
+
824
+ const valueSet = this.#toValueSet(collection);
825
+ if (!valueSet) {
826
+ return null;
827
+ }
828
+
829
+ this.#indexValueSet(valueSet);
830
+ return valueSet;
831
+ }
832
+
833
+ #valueSetBaseKey(vs) {
834
+ if (!vs || !vs.url) {
835
+ return null;
836
+ }
837
+ return `${vs.url}|${vs.version || ''}`;
838
+ }
839
+
840
+ #expansionParamsKey(params) {
841
+ if (!params || typeof params !== 'object') {
842
+ return 'default';
843
+ }
844
+
845
+ try {
846
+ const normalized = Object.keys(params)
847
+ .sort()
848
+ .reduce((acc, key) => {
849
+ if (key === 'tx-resource' || key === 'valueSet') {
850
+ return acc;
851
+ }
852
+ acc[key] = params[key];
853
+ return acc;
854
+ }, {});
855
+
856
+ const json = JSON.stringify(normalized);
857
+ if (!json || json === '{}') {
858
+ return 'default';
859
+ }
860
+ return crypto.createHash('sha256').update(json).digest('hex').substring(0, 16);
861
+ } catch (error) {
862
+ return 'default';
863
+ }
864
+ }
865
+
866
+ #expansionCacheKey(vs, paramsKey) {
867
+ const base = this.#valueSetBaseKey(vs);
868
+ if (!base) {
869
+ return null;
870
+ }
871
+ return `${base}|${paramsKey || 'default'}`;
872
+ }
873
+
874
+ #invalidateExpansionCache(vs) {
875
+ const base = this.#valueSetBaseKey(vs);
876
+ if (!base) {
877
+ return;
878
+ }
879
+
880
+ for (const key of this.backgroundExpansionCache.keys()) {
881
+ if (key.startsWith(`${base}|`)) {
882
+ this.backgroundExpansionCache.delete(key);
883
+ }
884
+ }
885
+ }
886
+
887
+ #applyCachedExpansion(vs, paramsKey) {
888
+ if (!vs || !vs.jsonObj) {
889
+ return;
890
+ }
891
+
892
+ const cacheKey = this.#expansionCacheKey(vs, paramsKey);
893
+ if (!cacheKey) {
894
+ return;
895
+ }
896
+
897
+ const cached = this.backgroundExpansionCache.get(cacheKey);
898
+ if (!cached || !cached.expansion) {
899
+ return;
900
+ }
901
+
902
+ if (!this.#isCachedExpansionValid(vs, cached)) {
903
+ this.backgroundExpansionCache.delete(cacheKey);
904
+ if (vs.jsonObj.expansion) {
905
+ delete vs.jsonObj.expansion;
906
+ }
907
+ console.log(`[OCL-ValueSet] Cached ValueSet expansion invalidated: ${cacheKey}`);
908
+ return;
909
+ }
910
+
911
+ if (vs.jsonObj.expansion) {
912
+ return;
913
+ }
914
+
915
+ vs.jsonObj.expansion = structuredClone(cached.expansion);
916
+ console.log(`[OCL-ValueSet] ValueSet expansion restored from cache: ${cacheKey}`);
917
+ }
918
+
919
+ #scheduleBackgroundExpansion(vs, options = {}) {
920
+ if (!vs || !vs.jsonObj) {
921
+ return;
922
+ }
923
+
924
+ const paramsKey = this.#expansionParamsKey(options.params || null);
925
+ const cacheKey = this.#expansionCacheKey(vs, paramsKey);
926
+ if (!cacheKey) {
927
+ return;
928
+ }
929
+
930
+ const cached = this.backgroundExpansionCache.get(cacheKey);
931
+ if (cached && !this.#isCachedExpansionValid(vs, cached)) {
932
+ this.backgroundExpansionCache.delete(cacheKey);
933
+ if (vs.jsonObj.expansion) {
934
+ delete vs.jsonObj.expansion;
935
+ }
936
+ console.log(`[OCL-ValueSet] Cached ValueSet expansion invalidated: ${cacheKey}`);
937
+ }
938
+
939
+ if (vs.jsonObj.expansion) {
940
+ return;
941
+ }
942
+
943
+ const cacheFilePath = getCacheFilePath(CACHE_VS_DIR, vs.url, vs.version || null, paramsKey);
944
+ const cacheAgeFromFileMs = getColdCacheAgeMs(cacheFilePath);
945
+ const persistedCache = this.backgroundExpansionCache.get(cacheKey);
946
+ const cacheAgeFromMetadataMs = Number.isFinite(persistedCache?.createdAt)
947
+ ? Math.max(0, Date.now() - persistedCache.createdAt)
948
+ : null;
949
+
950
+ // Treat cache as fresh when either file mtime or persisted timestamp is recent.
951
+ const freshnessCandidates = [cacheAgeFromFileMs, cacheAgeFromMetadataMs].filter(age => age != null);
952
+ const freshestCacheAgeMs = freshnessCandidates.length > 0 ? Math.min(...freshnessCandidates) : null;
953
+ if (freshestCacheAgeMs != null && freshestCacheAgeMs <= COLD_CACHE_FRESHNESS_MS) {
954
+ const freshnessSource = cacheAgeFromFileMs != null && cacheAgeFromMetadataMs != null
955
+ ? 'file+metadata'
956
+ : cacheAgeFromFileMs != null
957
+ ? 'file'
958
+ : 'metadata';
959
+ console.log(`[OCL-ValueSet] Skipping warm-up for ValueSet ${vs.url} (cold cache age: ${formatCacheAgeMinutes(freshestCacheAgeMs)})`);
960
+ console.log(`[OCL-ValueSet] ValueSet cold cache is fresh, not enqueueing warm-up job (${cacheKey}, source=${freshnessSource})`);
961
+ return;
962
+ }
963
+
964
+ const jobKey = `vs:${cacheKey}`;
965
+ if (OCLBackgroundJobQueue.isQueuedOrRunning(jobKey)) {
966
+ console.log(`[OCL-ValueSet] ValueSet expansion already queued or running: ${cacheKey}`);
967
+ return;
968
+ }
969
+
970
+ let queuedJobSize = null;
971
+ const warmupAgeText = freshestCacheAgeMs != null
972
+ ? formatCacheAgeMinutes(freshestCacheAgeMs)
973
+ : 'no cold cache';
974
+ console.log(`[OCL-ValueSet] Enqueueing warm-up for ValueSet ${vs.url} (cold cache age: ${warmupAgeText})`);
975
+ console.log(`[OCL-ValueSet] ValueSet expansion enqueued: ${cacheKey}`);
976
+ OCLBackgroundJobQueue.enqueue(
977
+ jobKey,
978
+ 'ValueSet expansion',
979
+ async () => {
980
+ await this.#runBackgroundExpansion(vs, cacheKey, paramsKey, queuedJobSize);
981
+ },
982
+ {
983
+ jobId: vs.url || cacheKey,
984
+ getProgress: () => this.#backgroundExpansionProgressSnapshot(cacheKey),
985
+ resolveJobSize: async () => {
986
+ const meta = this.#getCollectionMeta(vs);
987
+ queuedJobSize = await this.#fetchConceptCountFromHeaders(meta?.conceptsUrl || null);
988
+ return queuedJobSize;
989
+ }
990
+ }
991
+ );
992
+ }
993
+
994
+ async #runBackgroundExpansion(vs, cacheKey, paramsKey = 'default', knownConceptCount = null) {
995
+ console.log(`[OCL-ValueSet] ValueSet expansion started: ${cacheKey}`);
996
+ const progressState = { processed: 0, total: null };
997
+ this.backgroundExpansionProgress.set(cacheKey, progressState);
998
+ try {
999
+ await this.#ensureComposeIncludes(vs);
1000
+
1001
+ const meta = this.#getCollectionMeta(vs);
1002
+ const resolvedTotal = Number.isFinite(knownConceptCount) && knownConceptCount >= 0
1003
+ ? knownConceptCount
1004
+ : await this.#fetchConceptCountFromHeaders(meta?.conceptsUrl || null);
1005
+ progressState.total = resolvedTotal;
1006
+ const sources = meta ? await this.#fetchCollectionSources(meta) : [];
1007
+ for (const source of sources || []) {
1008
+ OCLSourceCodeSystemFactory.scheduleBackgroundLoadByKey(
1009
+ source.system,
1010
+ source.version || null,
1011
+ 'valueset-expansion'
1012
+ );
1013
+ }
1014
+
1015
+ const expansion = await this.#buildBackgroundExpansion(vs, progressState);
1016
+ if (!expansion) {
1017
+ return;
1018
+ }
1019
+
1020
+ progressState.processed = expansion.total || progressState.processed;
1021
+ if (typeof progressState.total !== 'number' || !Number.isFinite(progressState.total) || progressState.total <= 0) {
1022
+ progressState.total = expansion.total || 0;
1023
+ }
1024
+
1025
+ const metadataSignature = this.#valueSetMetadataSignature(vs);
1026
+ const dependencyChecksums = this.#valueSetDependencyChecksums(vs);
1027
+
1028
+ // Compute custom fingerprint and compare with cold cache
1029
+ const newFingerprint = computeValueSetExpansionFingerprint(expansion);
1030
+ const oldFingerprint = this.valueSetFingerprints.get(cacheKey);
1031
+
1032
+ if (oldFingerprint && newFingerprint === oldFingerprint) {
1033
+ console.log(`[OCL-ValueSet] ValueSet expansion fingerprint unchanged: ${cacheKey} (fingerprint=${newFingerprint?.substring(0, 8)})`);
1034
+ } else {
1035
+ if (oldFingerprint) {
1036
+ console.log(`[OCL-ValueSet] ValueSet expansion fingerprint changed: ${cacheKey} (${oldFingerprint?.substring(0, 8)} -> ${newFingerprint?.substring(0, 8)})`);
1037
+ console.log(`[OCL-ValueSet] Replacing cold cache with new hot cache: ${cacheKey}`);
1038
+ } else {
1039
+ console.log(`[OCL-ValueSet] Computed fingerprint for ValueSet expansion: ${cacheKey} (fingerprint=${newFingerprint?.substring(0, 8)})`);
1040
+ }
1041
+
1042
+ // Save to cold cache
1043
+ const savedFingerprint = await this.#saveColdCacheForValueSet(vs, expansion, metadataSignature, dependencyChecksums, paramsKey);
1044
+ if (savedFingerprint) {
1045
+ this.valueSetFingerprints.set(cacheKey, savedFingerprint);
1046
+ }
1047
+ }
1048
+
1049
+ this.backgroundExpansionCache.set(cacheKey, {
1050
+ expansion,
1051
+ metadataSignature,
1052
+ dependencyChecksums,
1053
+ createdAt: Date.now()
1054
+ });
1055
+ // Keep expansions in provider-managed cache only.
1056
+ // Inline expansion on ValueSet bypasses $expand filtering in worker pipeline.
1057
+
1058
+ console.log(`[OCL-ValueSet] ValueSet expansion completed and cached: ${cacheKey}`);
1059
+ console.log(`[OCL-ValueSet] ValueSet now available in cache: ${cacheKey}`);
1060
+ } catch (error) {
1061
+ console.error(`[OCL-ValueSet] ValueSet background expansion failed: ${cacheKey}: ${error.message}`);
1062
+ } finally {
1063
+ this.backgroundExpansionProgress.delete(cacheKey);
1064
+ }
1065
+ }
1066
+
1067
+ async #buildBackgroundExpansion(vs, progressState = null) {
1068
+ const meta = this.#getCollectionMeta(vs);
1069
+ if (!meta || !meta.conceptsUrl) {
1070
+ return null;
1071
+ }
1072
+
1073
+ const contains = [];
1074
+ let offset = 0;
1075
+
1076
+ // Pull all concepts in fixed-size pages until exhausted.
1077
+ // eslint-disable-next-line no-constant-condition
1078
+ while (true) {
1079
+ const batch = await this.#fetchCollectionConcepts(meta, {
1080
+ count: CONCEPT_PAGE_SIZE,
1081
+ offset,
1082
+ activeOnly: false,
1083
+ filter: null,
1084
+ languageCodes: []
1085
+ });
1086
+
1087
+ const entries = Array.isArray(batch?.contains) ? batch.contains : [];
1088
+ if (entries.length === 0) {
1089
+ break;
1090
+ }
1091
+
1092
+ for (const entry of entries) {
1093
+ if (!entry?.system || !entry?.code) {
1094
+ continue;
1095
+ }
1096
+
1097
+ const out = {
1098
+ system: entry.system,
1099
+ code: entry.code
1100
+ };
1101
+ if (entry.display) {
1102
+ out.display = entry.display;
1103
+ }
1104
+ if (entry.definition) {
1105
+ out.definition = entry.definition;
1106
+ }
1107
+ if (entry.inactive === true) {
1108
+ out.inactive = true;
1109
+ }
1110
+ if (Array.isArray(entry.designation) && entry.designation.length > 0) {
1111
+ out.designation = entry.designation
1112
+ .filter(d => d && d.value)
1113
+ .map(d => ({
1114
+ language: d.language,
1115
+ value: d.value
1116
+ }));
1117
+ }
1118
+ contains.push(out);
1119
+ }
1120
+
1121
+ if (progressState) {
1122
+ progressState.processed = contains.length;
1123
+ }
1124
+
1125
+ if (entries.length < CONCEPT_PAGE_SIZE) {
1126
+ break;
1127
+ }
1128
+ offset += entries.length;
1129
+ }
1130
+
1131
+ return {
1132
+ timestamp: new Date().toISOString(),
1133
+ identifier: `urn:uuid:${crypto.randomUUID()}`,
1134
+ total: contains.length,
1135
+ contains
1136
+ };
1137
+ }
1138
+
1139
+ async #findCollectionByCanonical(canonicalUrl, version) {
1140
+ const lookupKey = `${this.org || '*'}|${canonicalUrl}|${version || ''}`;
1141
+ if (this.collectionByCanonicalCache.has(lookupKey)) {
1142
+ return this.collectionByCanonicalCache.get(lookupKey);
1143
+ }
1144
+ if (this.pendingCollectionByCanonicalRequests.has(lookupKey)) {
1145
+ return this.pendingCollectionByCanonicalRequests.get(lookupKey);
1146
+ }
1147
+
1148
+ const token = this.#canonicalToken(canonicalUrl);
1149
+ if (!token) {
1150
+ return null;
1151
+ }
1152
+
1153
+ const pending = (async () => {
1154
+ const organizations = await this.#fetchOrganizationIds();
1155
+ const endpoints = organizations.length > 0
1156
+ ? organizations.map(orgId => `/orgs/${encodeURIComponent(orgId)}/collections/`)
1157
+ : ['/collections/'];
1158
+
1159
+ const exactMatches = [];
1160
+ for (const endpoint of endpoints) {
1161
+ const response = await this.httpClient.get(endpoint, {
1162
+ params: {
1163
+ q: token,
1164
+ page: 1,
1165
+ limit: PAGE_SIZE
1166
+ }
1167
+ });
1168
+
1169
+ const payload = response.data;
1170
+ const items = Array.isArray(payload)
1171
+ ? payload
1172
+ : Array.isArray(payload?.results)
1173
+ ? payload.results
1174
+ : Array.isArray(payload?.items)
1175
+ ? payload.items
1176
+ : Array.isArray(payload?.data)
1177
+ ? payload.data
1178
+ : [];
1179
+
1180
+ for (const item of items) {
1181
+ const itemCanonical = item?.canonical_url || item?.canonicalUrl || item?.url || null;
1182
+ if (itemCanonical === canonicalUrl) {
1183
+ exactMatches.push(item);
1184
+ }
1185
+ }
1186
+ }
1187
+
1188
+ let match = null;
1189
+ if (exactMatches.length > 0) {
1190
+ if (!version) {
1191
+ match = exactMatches[0];
1192
+ } else {
1193
+ const exactVersion = exactMatches.find(item => (item.version || null) === version);
1194
+ if (exactVersion) {
1195
+ match = exactVersion;
1196
+ } else if (VersionUtilities.isSemVer(version)) {
1197
+ const majorMinor = VersionUtilities.getMajMin(version);
1198
+ if (majorMinor) {
1199
+ const majorMinorMatch = exactMatches.find(item => (item.version || null) === majorMinor);
1200
+ if (majorMinorMatch) {
1201
+ match = majorMinorMatch;
1202
+ }
1203
+ }
1204
+ }
1205
+ }
1206
+ }
1207
+
1208
+ this.collectionByCanonicalCache.set(lookupKey, match);
1209
+ return match;
1210
+ })();
1211
+
1212
+ this.pendingCollectionByCanonicalRequests.set(lookupKey, pending);
1213
+ try {
1214
+ return await pending;
1215
+ } finally {
1216
+ this.pendingCollectionByCanonicalRequests.delete(lookupKey);
1217
+ }
1218
+ }
1219
+
1220
+ #valueSetMetadataSignature(vs) {
1221
+ const meta = this.#getCollectionMeta(vs);
1222
+ const payload = {
1223
+ url: vs?.url || null,
1224
+ version: vs?.version || null,
1225
+ lastUpdated: vs?.jsonObj?.meta?.lastUpdated || null,
1226
+ collectionId: meta?.collectionId || null,
1227
+ conceptsUrl: meta?.conceptsUrl || null,
1228
+ expansionUrl: meta?.expansionUrl || null,
1229
+ preferredSource: meta?.preferredSource || null
1230
+ };
1231
+ return JSON.stringify(payload);
1232
+ }
1233
+
1234
+ #valueSetDependencyChecksums(vs) {
1235
+ const include = Array.isArray(vs?.jsonObj?.compose?.include) ? vs.jsonObj.compose.include : [];
1236
+ const checksums = {};
1237
+ for (const item of include) {
1238
+ const system = normalizeCanonicalSystem(item?.system || null);
1239
+ if (!system) {
1240
+ continue;
1241
+ }
1242
+ const version = item?.version || null;
1243
+ const key = `${system}|${version || ''}`;
1244
+ checksums[key] = OCLSourceCodeSystemFactory.checksumForResource(system, version);
1245
+ }
1246
+ return checksums;
1247
+ }
1248
+
1249
+ #isCachedExpansionValid(vs, cached) {
1250
+ if (!cached || typeof cached !== 'object') {
1251
+ return false;
1252
+ }
1253
+
1254
+ if (cached.metadataSignature !== this.#valueSetMetadataSignature(vs)) {
1255
+ return false;
1256
+ }
1257
+
1258
+ const currentDeps = this.#valueSetDependencyChecksums(vs);
1259
+ const cachedDeps = cached.dependencyChecksums || {};
1260
+ const currentKeys = Object.keys(currentDeps).sort();
1261
+ const cachedKeys = Object.keys(cachedDeps).sort();
1262
+
1263
+ if (currentKeys.length !== cachedKeys.length) {
1264
+ return false;
1265
+ }
1266
+
1267
+ for (let i = 0; i < currentKeys.length; i++) {
1268
+ if (currentKeys[i] !== cachedKeys[i]) {
1269
+ return false;
1270
+ }
1271
+ if ((currentDeps[currentKeys[i]] || null) !== (cachedDeps[cachedKeys[i]] || null)) {
1272
+ return false;
1273
+ }
1274
+ }
1275
+
1276
+ return true;
1277
+ }
1278
+
1279
+ async #fetchCollectionsForDiscovery() {
1280
+ const organizations = await this.#fetchOrganizationIds();
1281
+ if (organizations.length === 0) {
1282
+ // Fallback for OCL instances that expose global listing but not org listing.
1283
+ return await this.#fetchAllPages('/collections/');
1284
+ }
1285
+
1286
+ const allCollections = [];
1287
+ const seen = new Set();
1288
+
1289
+ for (const orgId of organizations) {
1290
+ const endpoint = `/orgs/${encodeURIComponent(orgId)}/collections/`;
1291
+ const collections = await this.#fetchAllPages(endpoint);
1292
+ for (const collection of collections) {
1293
+ if (!collection || typeof collection !== 'object') {
1294
+ continue;
1295
+ }
1296
+ const key = this.#collectionIdentity(collection);
1297
+ if (seen.has(key)) {
1298
+ continue;
1299
+ }
1300
+ seen.add(key);
1301
+ allCollections.push(collection);
1302
+ }
1303
+ }
1304
+
1305
+ return allCollections;
1306
+ }
1307
+
1308
+ async #fetchOrganizationIds() {
1309
+ const endpoint = '/orgs/';
1310
+ console.log(`[OCL-ValueSet] Loading organizations from: ${this.baseUrl}${endpoint}`);
1311
+ const orgs = await this.#fetchAllPages(endpoint);
1312
+
1313
+ const ids = [];
1314
+ const seen = new Set();
1315
+ for (const org of orgs || []) {
1316
+ if (!org || typeof org !== 'object') {
1317
+ continue;
1318
+ }
1319
+
1320
+ const id = org.id || org.mnemonic || org.short_code || org.shortCode || org.name || null;
1321
+ if (!id) {
1322
+ continue;
1323
+ }
1324
+
1325
+ const normalized = String(id).trim();
1326
+ if (!normalized || seen.has(normalized)) {
1327
+ continue;
1328
+ }
1329
+
1330
+ seen.add(normalized);
1331
+ ids.push(normalized);
1332
+ }
1333
+
1334
+ if (ids.length === 0 && this.org) {
1335
+ ids.push(this.org);
1336
+ }
1337
+
1338
+ return ids;
1339
+ }
1340
+
1341
+ #collectionIdentity(collection) {
1342
+ if (!collection || typeof collection !== 'object') {
1343
+ return '__invalid__';
1344
+ }
1345
+
1346
+ const owner = collection.owner || '';
1347
+ const canonical = collection.canonical_url || collection.canonicalUrl || '';
1348
+ const id = collection.id || collection.short_code || collection.shortCode || collection.name || '';
1349
+ return `${owner}|${canonical}|${id}`;
1350
+ }
1351
+
1352
+ #canonicalToken(canonicalUrl) {
1353
+ if (!canonicalUrl || typeof canonicalUrl !== 'string') {
1354
+ return null;
1355
+ }
1356
+
1357
+ const trimmed = canonicalUrl.trim();
1358
+ if (!trimmed) {
1359
+ return null;
1360
+ }
1361
+
1362
+ const parts = trimmed.replace(/\/+$/, '').split('/').filter(Boolean);
1363
+ if (parts.length === 0) {
1364
+ return null;
1365
+ }
1366
+
1367
+ return parts[parts.length - 1];
1368
+ }
1369
+
1370
+ async #fetchConceptPage(conceptsUrl, page, limit, filter = null) {
1371
+ try {
1372
+ const cacheKey = `${conceptsUrl}|p=${page}|l=${limit}|q=${filter || ''}|verbose=1`;
1373
+ if (this.collectionConceptPageCache.has(cacheKey)) {
1374
+ const cached = this.collectionConceptPageCache.get(cacheKey);
1375
+ const items = Array.isArray(cached)
1376
+ ? cached
1377
+ : Array.isArray(cached?.items)
1378
+ ? cached.items
1379
+ : [];
1380
+ return {
1381
+ items,
1382
+ reportedTotal: this.#extractTotalFromPayload(cached?.payload || null)
1383
+ };
1384
+ }
1385
+ if (this.pendingCollectionConceptPageRequests.has(cacheKey)) {
1386
+ return this.pendingCollectionConceptPageRequests.get(cacheKey);
1387
+ }
1388
+
1389
+ const pending = (async () => {
1390
+ const params = { page, limit, verbose: true };
1391
+ if (filter) {
1392
+ params.q = filter;
1393
+ }
1394
+ const response = await this.httpClient.get(conceptsUrl, { params });
1395
+ const payload = response.data;
1396
+ const items = Array.isArray(payload)
1397
+ ? payload
1398
+ : Array.isArray(payload?.results)
1399
+ ? payload.results
1400
+ : Array.isArray(payload?.items)
1401
+ ? payload.items
1402
+ : Array.isArray(payload?.data)
1403
+ ? payload.data
1404
+ : [];
1405
+
1406
+ this.collectionConceptPageCache.set(cacheKey, { items, payload });
1407
+ return {
1408
+ items,
1409
+ reportedTotal: this.#extractTotalFromPayload(payload)
1410
+ };
1411
+ })();
1412
+
1413
+ this.pendingCollectionConceptPageRequests.set(cacheKey, pending);
1414
+ try {
1415
+ return await pending;
1416
+ } finally {
1417
+ this.pendingCollectionConceptPageRequests.delete(cacheKey);
1418
+ }
1419
+ } catch (error) {
1420
+ console.error(`[OCL-ValueSet] #fetchConceptPage ERROR: ${error.message}`);
1421
+ throw error;
1422
+ }
1423
+ }
1424
+
1425
+ #buildRemoteQuery(filter) {
1426
+ if (!filter || typeof filter !== 'string') {
1427
+ return null;
1428
+ }
1429
+
1430
+ const tokens = filter
1431
+ .split(/\s+or\s+|\||&|\s+/i)
1432
+ .map(t => t.trim())
1433
+ .filter(Boolean)
1434
+ .map(t => (t.startsWith('-') || t.startsWith('!')) ? t.substring(1) : t)
1435
+ .map(t => t.replace(/[%*?]/g, ''))
1436
+ .map(t => t.replace(/[^\p{L}\p{N}]+/gu, ''))
1437
+ .filter(t => t.length >= 3);
1438
+
1439
+ if (tokens.length === 0) {
1440
+ return null;
1441
+ }
1442
+
1443
+ tokens.sort((a, b) => b.length - a.length);
1444
+ return tokens[0];
1445
+ }
1446
+
1447
+ #normalizeLanguageCodes(languageCodes) {
1448
+ if (!Array.isArray(languageCodes)) {
1449
+ return [];
1450
+ }
1451
+
1452
+ const normalized = [];
1453
+ for (const code of languageCodes) {
1454
+ if (!code || typeof code !== 'string') {
1455
+ continue;
1456
+ }
1457
+ normalized.push(code.toLowerCase());
1458
+ }
1459
+ return normalized;
1460
+ }
1461
+
1462
+ #backgroundExpansionProgressSnapshot(cacheKey) {
1463
+ const progress = this.backgroundExpansionProgress.get(cacheKey);
1464
+ if (!progress) {
1465
+ return null;
1466
+ }
1467
+
1468
+ const processed = progress.processed;
1469
+ const total = progress.total;
1470
+ if (
1471
+ typeof processed === 'number' &&
1472
+ Number.isFinite(processed) &&
1473
+ typeof total === 'number' &&
1474
+ Number.isFinite(total) &&
1475
+ total > 0
1476
+ ) {
1477
+ return { processed, total };
1478
+ }
1479
+
1480
+ return null;
1481
+ }
1482
+
1483
+ #extractTotalFromPayload(payload) {
1484
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
1485
+ return null;
1486
+ }
1487
+
1488
+ const candidates = [
1489
+ payload.total,
1490
+ payload.total_count,
1491
+ payload.totalCount,
1492
+ payload.num_found,
1493
+ payload.numFound,
1494
+ payload.count
1495
+ ];
1496
+
1497
+ for (const candidate of candidates) {
1498
+ if (typeof candidate === 'number' && Number.isFinite(candidate) && candidate >= 0) {
1499
+ return candidate;
1500
+ }
1501
+ }
1502
+
1503
+ return null;
1504
+ }
1505
+
1506
+ async #fetchConceptCountFromHeaders(conceptsUrl) {
1507
+ if (!conceptsUrl) {
1508
+ return null;
1509
+ }
1510
+
1511
+ try {
1512
+ const response = await this.httpClient.get(conceptsUrl, {
1513
+ params: {
1514
+ limit: 1
1515
+ }
1516
+ });
1517
+ return this.#extractNumFoundFromHeaders(response?.headers);
1518
+ } catch (error) {
1519
+ return null;
1520
+ }
1521
+ }
1522
+
1523
+ #extractNumFoundFromHeaders(headers) {
1524
+ if (!headers || typeof headers !== 'object') {
1525
+ return null;
1526
+ }
1527
+
1528
+ const raw = headers.num_found ?? headers['num-found'] ?? headers.Num_Found ?? null;
1529
+ const parsed = typeof raw === 'number' ? raw : Number.parseInt(raw, 10);
1530
+ if (!Number.isFinite(parsed) || parsed < 0) {
1531
+ return null;
1532
+ }
1533
+ return parsed;
1534
+ }
1535
+
1536
+ #languageRank(languageCode, preferredLanguageCodes) {
1537
+ if (!languageCode) {
1538
+ return 1000;
1539
+ }
1540
+
1541
+ const normalized = String(languageCode).toLowerCase();
1542
+ for (let i = 0; i < preferredLanguageCodes.length; i++) {
1543
+ const preferred = preferredLanguageCodes[i];
1544
+ if (normalized === preferred || normalized.startsWith(`${preferred}-`) || preferred.startsWith(`${normalized}-`)) {
1545
+ return i;
1546
+ }
1547
+ }
1548
+
1549
+ if (normalized === 'en' || normalized.startsWith('en-')) {
1550
+ return preferredLanguageCodes.length;
1551
+ }
1552
+
1553
+ return preferredLanguageCodes.length + 1;
1554
+ }
1555
+
1556
+ #extractLocalizedNames(concept, preferredLanguageCodes) {
1557
+ const names = Array.isArray(concept?.names) ? concept.names : [];
1558
+ const unique = new Map();
1559
+
1560
+ for (const item of names) {
1561
+ const value = item?.name;
1562
+ if (!value || typeof value !== 'string') {
1563
+ continue;
1564
+ }
1565
+
1566
+ const language = typeof item?.locale === 'string' && item.locale.trim() ? item.locale.trim().toLowerCase() : null;
1567
+ const key = `${language || ''}|${value}`;
1568
+ if (unique.has(key)) {
1569
+ continue;
1570
+ }
1571
+
1572
+ unique.set(key, {
1573
+ language: language || undefined,
1574
+ value,
1575
+ localePreferred: item?.locale_preferred === true,
1576
+ nameType: item?.name_type || ''
1577
+ });
1578
+ }
1579
+
1580
+ const designation = Array.from(unique.values())
1581
+ .sort((a, b) => {
1582
+ const rankDiff = this.#languageRank(a.language, preferredLanguageCodes) - this.#languageRank(b.language, preferredLanguageCodes);
1583
+ if (rankDiff !== 0) {
1584
+ return rankDiff;
1585
+ }
1586
+ if (a.localePreferred !== b.localePreferred) {
1587
+ return a.localePreferred ? -1 : 1;
1588
+ }
1589
+ const aFs = /fully\s*-?\s*specified/i.test(a.nameType);
1590
+ const bFs = /fully\s*-?\s*specified/i.test(b.nameType);
1591
+ if (aFs !== bFs) {
1592
+ return aFs ? -1 : 1;
1593
+ }
1594
+ return a.value.localeCompare(b.value);
1595
+ })
1596
+ .map(({ language, value }) => ({
1597
+ language,
1598
+ value
1599
+ }));
1600
+
1601
+ return {
1602
+ display: designation.length > 0 ? designation[0].value : null,
1603
+ designation
1604
+ };
1605
+ }
1606
+
1607
+ #extractLocalizedDefinitions(concept, preferredLanguageCodes) {
1608
+ const descriptions = Array.isArray(concept?.descriptions) ? concept.descriptions : [];
1609
+ const unique = new Map();
1610
+
1611
+ for (const item of descriptions) {
1612
+ const value = item?.description;
1613
+ if (!value || typeof value !== 'string') {
1614
+ continue;
1615
+ }
1616
+
1617
+ const language = typeof item?.locale === 'string' && item.locale.trim() ? item.locale.trim().toLowerCase() : null;
1618
+ const key = `${language || ''}|${value}`;
1619
+ if (unique.has(key)) {
1620
+ continue;
1621
+ }
1622
+
1623
+ unique.set(key, {
1624
+ language: language || undefined,
1625
+ value,
1626
+ localePreferred: item?.locale_preferred === true,
1627
+ descriptionType: item?.description_type || ''
1628
+ });
1629
+ }
1630
+
1631
+ const definitions = Array.from(unique.values())
1632
+ .sort((a, b) => {
1633
+ const rankDiff = this.#languageRank(a.language, preferredLanguageCodes) - this.#languageRank(b.language, preferredLanguageCodes);
1634
+ if (rankDiff !== 0) {
1635
+ return rankDiff;
1636
+ }
1637
+ if (a.localePreferred !== b.localePreferred) {
1638
+ return a.localePreferred ? -1 : 1;
1639
+ }
1640
+ const aDef = /definition/i.test(a.descriptionType);
1641
+ const bDef = /definition/i.test(b.descriptionType);
1642
+ if (aDef !== bDef) {
1643
+ return aDef ? -1 : 1;
1644
+ }
1645
+ return a.value.localeCompare(b.value);
1646
+ })
1647
+ .map(({ language, value }) => ({
1648
+ language,
1649
+ value
1650
+ }));
1651
+
1652
+ return {
1653
+ definition: definitions.length > 0 ? definitions[0].value : null,
1654
+ definitions
1655
+ };
1656
+ }
1657
+
1658
+ async #fetchCollectionSourceKeys(conceptsUrl, defaultOwner) {
1659
+ const keys = new Map();
1660
+ let page = 1;
1661
+
1662
+ // eslint-disable-next-line no-constant-condition
1663
+ while (true) {
1664
+ const response = await this.httpClient.get(conceptsUrl, { params: { page, limit: CONCEPT_PAGE_SIZE } });
1665
+ const payload = response.data;
1666
+
1667
+ let items = [];
1668
+ if (Array.isArray(payload)) {
1669
+ items = payload;
1670
+ } else if (payload && typeof payload === 'object') {
1671
+ items = Array.isArray(payload.results)
1672
+ ? payload.results
1673
+ : Array.isArray(payload.items)
1674
+ ? payload.items
1675
+ : Array.isArray(payload.data)
1676
+ ? payload.data
1677
+ : [];
1678
+ }
1679
+
1680
+ if (!items || items.length === 0) {
1681
+ break;
1682
+ }
1683
+
1684
+ for (const concept of items) {
1685
+ const owner = concept.owner || defaultOwner || null;
1686
+ const source = concept.source || null;
1687
+ if (owner && source) {
1688
+ keys.set(`${owner}|${source}`, { owner, source });
1689
+ }
1690
+ }
1691
+
1692
+ if (items.length < CONCEPT_PAGE_SIZE) {
1693
+ break;
1694
+ }
1695
+
1696
+ page += 1;
1697
+ }
1698
+
1699
+ return Array.from(keys.values());
1700
+ }
1701
+
1702
+ async #getSourceCanonicalUrl(owner, source) {
1703
+ const key = `${owner}|${source}`;
1704
+ if (this.sourceCanonicalCache.has(key)) {
1705
+ return this.sourceCanonicalCache.get(key);
1706
+ }
1707
+ if (this.pendingSourceCanonicalRequests.has(key)) {
1708
+ return this.pendingSourceCanonicalRequests.get(key);
1709
+ }
1710
+
1711
+ const path = `/orgs/${encodeURIComponent(owner)}/sources/${encodeURIComponent(source)}/`;
1712
+ const pending = (async () => {
1713
+ try {
1714
+ const response = await this.httpClient.get(path);
1715
+ const data = response.data || {};
1716
+ const canonicalUrl = data.canonical_url || data.canonicalUrl || data.url || source;
1717
+ this.sourceCanonicalCache.set(key, canonicalUrl);
1718
+ return canonicalUrl;
1719
+ } catch (error) {
1720
+ this.sourceCanonicalCache.set(key, source);
1721
+ return source;
1722
+ }
1723
+ })();
1724
+
1725
+ this.pendingSourceCanonicalRequests.set(key, pending);
1726
+ try {
1727
+ return await pending;
1728
+ } finally {
1729
+ this.pendingSourceCanonicalRequests.delete(key);
1730
+ }
1731
+ }
1732
+
1733
+ #matches(json, params) {
1734
+ for (const [name, value] of Object.entries(params)) {
1735
+ if (!value) {
1736
+ continue;
1737
+ }
1738
+
1739
+ switch (name) {
1740
+ case 'url':
1741
+ if ((json.url || '').toLowerCase() !== value) {
1742
+ return false;
1743
+ }
1744
+ break;
1745
+ case 'system':
1746
+ if (!json.compose?.include?.some(i => (i.system || '').toLowerCase().includes(value))) {
1747
+ return false;
1748
+ }
1749
+ break;
1750
+ case 'identifier': {
1751
+ const identifiers = Array.isArray(json.identifier) ? json.identifier : (json.identifier ? [json.identifier] : []);
1752
+ const match = identifiers.some(i => (i.system || '').toLowerCase().includes(value) || (i.value || '').toLowerCase().includes(value));
1753
+ if (!match) {
1754
+ return false;
1755
+ }
1756
+ break;
1757
+ }
1758
+ default: {
1759
+ const field = json[name];
1760
+ if (field == null || !String(field).toLowerCase().includes(value)) {
1761
+ return false;
1762
+ }
1763
+ break;
1764
+ }
1765
+ }
1766
+ }
1767
+ return true;
1768
+ }
1769
+
1770
+ async #fetchAllPages(path) {
1771
+ const results = [];
1772
+ let page = 1;
1773
+
1774
+ // eslint-disable-next-line no-constant-condition
1775
+ while (true) {
1776
+ const response = await this.httpClient.get(path, { params: { page, limit: PAGE_SIZE } });
1777
+ const payload = response.data;
1778
+
1779
+ let items = [];
1780
+ if (Array.isArray(payload)) {
1781
+ items = payload;
1782
+ } else if (payload && typeof payload === 'object') {
1783
+ items = Array.isArray(payload.results)
1784
+ ? payload.results
1785
+ : Array.isArray(payload.items)
1786
+ ? payload.items
1787
+ : Array.isArray(payload.data)
1788
+ ? payload.data
1789
+ : [];
1790
+ }
1791
+
1792
+ if (!items || items.length === 0) {
1793
+ break;
1794
+ }
1795
+
1796
+ results.push(...items);
1797
+
1798
+ if (items.length < PAGE_SIZE) {
1799
+ break;
1800
+ }
1801
+
1802
+ page += 1;
1803
+ }
1804
+
1805
+ return results;
1806
+ }
1807
+
1808
+ #toIsoDate(value) {
1809
+ if (!value) {
1810
+ return null;
1811
+ }
1812
+ const date = new Date(value);
1813
+ if (Number.isNaN(date.getTime())) {
1814
+ return null;
1815
+ }
1816
+ return date.toISOString();
1817
+ }
1818
+
1819
+ #normalizeFilter(filter) {
1820
+ if (typeof filter !== 'string') {
1821
+ return null;
1822
+ }
1823
+ const normalized = filter.trim().toLowerCase();
1824
+ return normalized.length > 0 ? normalized : null;
1825
+ }
1826
+
1827
+ #conceptMatchesFilter(searchableText, code, display, definition, filter, filterMatcher) {
1828
+ if (!filter) {
1829
+ return true;
1830
+ }
1831
+
1832
+ const codeText = code ? String(code).toLowerCase() : '';
1833
+ const displayText = display ? String(display).toLowerCase() : '';
1834
+ const definitionText = definition ? String(definition).toLowerCase() : '';
1835
+
1836
+ // FHIR allows terminology-server-defined behavior; guarantee baseline contains matching.
1837
+ if (codeText.includes(filter) || displayText.includes(filter) || definitionText.includes(filter)) {
1838
+ return true;
1839
+ }
1840
+
1841
+ // Preserve token/prefix behavior already provided by SearchFilterText.
1842
+ return !!(filterMatcher && filterMatcher.passes(searchableText));
1843
+ }
1844
+ }
1845
+
1846
+ module.exports = {
1847
+ OCLValueSetProvider
1848
+ };