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
@@ -20,7 +20,13 @@ class AbstractCodeSystemProvider {
20
20
  }
21
21
 
22
22
  /**
23
- * Returns the list of CodeSystems this provider provides
23
+ * Returns the list of CodeSystems this provider provides. This is called once at start up.
24
+ * The code systems should be fully loaded; lazy loading code systems is not considered good
25
+ * for engineering.
26
+ *
27
+ *
28
+ * Note that unlike value sets, which are accessed from the provider on the fly, code systems
29
+ * are all preloaded into the kernel (e.g. provider) at start up
24
30
  *
25
31
  * @param {string} fhirVersion - The FHIRVersion in scope - if relevant (there's always a stated version, though R5 is always used)
26
32
  * @param {string} context - The client's stated context - if provided.
@@ -32,6 +38,24 @@ class AbstractCodeSystemProvider {
32
38
  throw new Error('listCodeSystems must be implemented by AbstractCodeSystemProvider subclass');
33
39
  }
34
40
 
41
+ /**
42
+ * This is called once a minute to update the code system list that the provider maintains.
43
+ *
44
+ * return an object that has three Map<String, CodeSystem>: {added, changed, deleted}
45
+ *
46
+ * these use the same key as the
47
+ *
48
+ * code systems are identified by url and version
49
+ *
50
+ * @param fhirVersion
51
+ * @param context
52
+ * @returns {Promise<null>}
53
+ */
54
+ // eslint-disable-next-line no-unused-vars
55
+ async getCodeSystemChanges(fhirVersion, context){
56
+ return null;
57
+ }
58
+
35
59
  async close() {
36
60
 
37
61
  }
@@ -7,7 +7,7 @@ class ListCodeSystemProvider extends AbstractCodeSystemProvider {
7
7
  /**
8
8
  * {Map<String, CodeSystem>} A list of code system factories that contains all the preloaded native code systems
9
9
  */
10
- codeSystems = new Map();
10
+ codeSystems = [];
11
11
 
12
12
  /**
13
13
  * ensure that the ids on the code systems are unique, if they are
@@ -17,7 +17,7 @@ class ListCodeSystemProvider extends AbstractCodeSystemProvider {
17
17
  */
18
18
  // eslint-disable-next-line no-unused-vars
19
19
  assignIds(ids) {
20
- for (const cs of this.codeSystems.values()) {
20
+ for (const cs of this.codeSystems) {
21
21
  if (!cs.id || ids.has("CodeSystem/"+cs.id)) {
22
22
  cs.id = ""+ids.size;
23
23
  }
@@ -111,7 +111,12 @@ class CanonicalResource {
111
111
  const fmt = this.versionAlgorithm() || other.versionAlgorithm() || this.guessVersionAlgorithmFromVersion(this.version);
112
112
  switch (fmt) {
113
113
  case 'semver':
114
- return VersionUtilities.isThisOrLater(other.version, this.version, VersionPrecision.PATCH);
114
+ try {
115
+ return VersionUtilities.isThisOrLater(other.version, this.version, VersionPrecision.PATCH);
116
+ } catch (error) {
117
+ // other is not semver. Not much we can do
118
+ return false;
119
+ }
115
120
  case 'date':
116
121
  return this.dateIsMoreRecent(this.version, other.version);
117
122
  case 'integer':
package/tx/library.js CHANGED
@@ -31,6 +31,9 @@ const { Provider } = require("./provider");
31
31
  const {I18nSupport} = require("../library/i18nsupport");
32
32
  const folders = require('../library/folder-setup');
33
33
  const {VSACValueSetProvider} = require("./vs/vs-vsac");
34
+ const { OCLCodeSystemProvider, OCLSourceCodeSystemFactory } = require('./ocl/cs-ocl');
35
+ const { OCLValueSetProvider } = require('./ocl/vs-ocl');
36
+ const { OCLConceptMapProvider } = require('./ocl/cm-ocl');
34
37
 
35
38
  /**
36
39
  * This class holds all the loaded content ready for processing
@@ -95,6 +98,8 @@ class Library {
95
98
  this.codeSystemProviders = [];
96
99
  this.valueSetProviders = [];
97
100
  this.conceptMapProviders = [];
101
+ this.oclProviderSets = new Map();
102
+ this.oclConfig = {};
98
103
 
99
104
  // Create package manager for FHIR packages
100
105
  const packageServers = ['https://packages2.fhir.org/packages'];
@@ -154,6 +159,7 @@ class Library {
154
159
  const yamlContent = await fs.readFile(yamlPath, 'utf8');
155
160
  const config = yaml.parse(yamlContent);
156
161
  this.baseUrl = config.base.url;
162
+ this.oclConfig = config.ocl && typeof config.ocl === 'object' ? config.ocl : {};
157
163
 
158
164
  this.log.info('Fetching Data from '+this.baseUrl);
159
165
 
@@ -277,12 +283,127 @@ class Library {
277
283
  case 'url/cs':
278
284
  await this.loadUrl(packageManager, details, isDefault, mode, true);
279
285
  break;
286
+
287
+ case 'ocl':
288
+ await this.loadOcl(details, isDefault, mode);
289
+ break;
280
290
 
281
291
  default:
282
292
  throw new Error(`Unknown source type: ${type}`);
283
293
  }
284
294
  }
285
295
 
296
+ parseOclConfig(details) {
297
+ const text = String(details || '').trim();
298
+ if (!text) {
299
+ throw new Error('OCL source requires details, e.g. ocl:https://ocl.example.org');
300
+ }
301
+
302
+ const parts = text.split('|').map(p => p.trim()).filter(Boolean);
303
+ const baseUrl = this.resolveOclConfigValue(parts[0]);
304
+ if (!baseUrl) {
305
+ throw new Error('OCL source requires a base URL');
306
+ }
307
+
308
+ const config = { baseUrl };
309
+ for (let i = 1; i < parts.length; i++) {
310
+ const part = parts[i];
311
+ const eq = part.indexOf('=');
312
+ if (eq === -1) {
313
+ continue;
314
+ }
315
+ const key = part.substring(0, eq).trim().toLowerCase();
316
+ const value = this.resolveOclConfigValue(part.substring(eq + 1).trim());
317
+ if (!value) {
318
+ continue;
319
+ }
320
+ if (key === 'org') {
321
+ config.org = value;
322
+ } else if (key === 'token') {
323
+ config.token = value;
324
+ } else if (key === 'timeout') {
325
+ const timeout = Number(value);
326
+ if (Number.isFinite(timeout) && timeout > 0) {
327
+ config.timeout = timeout;
328
+ }
329
+ }
330
+ }
331
+
332
+ return config;
333
+ }
334
+
335
+ resolveOclConfigValue(value) {
336
+ const text = String(value || '').trim();
337
+ if (!text) {
338
+ return text;
339
+ }
340
+
341
+ if (this.oclConfig && Object.hasOwn(this.oclConfig, text)) {
342
+ const resolved = this.oclConfig[text];
343
+ if (typeof resolved === 'string') {
344
+ return resolved.trim();
345
+ }
346
+ }
347
+
348
+ return text;
349
+ }
350
+
351
+ async loadOcl(details, isDefault, mode) {
352
+ const config = this.parseOclConfig(details);
353
+ const cacheKey = `${config.baseUrl}|${config.org || ''}`;
354
+
355
+ let providerSet = this.oclProviderSets.get(cacheKey);
356
+ if (!providerSet) {
357
+ const codeSystemProvider = new OCLCodeSystemProvider(config);
358
+ const valueSetProvider = new OCLValueSetProvider(config);
359
+ const conceptMapProvider = new OCLConceptMapProvider(config);
360
+ providerSet = {
361
+ config,
362
+ codeSystemProvider,
363
+ valueSetProvider,
364
+ conceptMapProvider,
365
+ csRegistered: false,
366
+ factoriesRegistered: false,
367
+ vsRegistered: false,
368
+ cmRegistered: false
369
+ };
370
+ this.oclProviderSets.set(cacheKey, providerSet);
371
+ }
372
+
373
+ if (mode === 'fetch') {
374
+ return;
375
+ }
376
+
377
+ if (mode === 'cs') {
378
+ if (!providerSet.csRegistered) {
379
+ this.codeSystemProviders.push(providerSet.codeSystemProvider);
380
+ providerSet.csRegistered = true;
381
+ }
382
+
383
+ if (!providerSet.factoriesRegistered) {
384
+ await providerSet.codeSystemProvider.listCodeSystems('5.0', null);
385
+ const metas = providerSet.codeSystemProvider.getSourceMetas();
386
+ for (const meta of metas) {
387
+ const factory = new OCLSourceCodeSystemFactory(this.i18n, providerSet.codeSystemProvider.httpClient, meta);
388
+ this.registerProvider(`ocl:${config.baseUrl}`, factory, isDefault);
389
+ }
390
+ providerSet.factoriesRegistered = true;
391
+ }
392
+ return;
393
+ }
394
+
395
+ if (mode === 'npm') {
396
+ if (!providerSet.vsRegistered) {
397
+ this.valueSetProviders.push(providerSet.valueSetProvider);
398
+ providerSet.vsRegistered = true;
399
+ }
400
+ if (!providerSet.cmRegistered) {
401
+ this.conceptMapProviders.push(providerSet.conceptMapProvider);
402
+ providerSet.cmRegistered = true;
403
+ }
404
+ }
405
+ }
406
+
286
407
  async loadInternal(details, isDefault, mode) {
287
408
  if (isDefault) {
288
409
  throw new Error("Default is not supported for internal code system providers");
@@ -464,13 +585,7 @@ class Library {
464
585
  for (const resource of resources) {
465
586
  const cs = new CodeSystem(await contentLoader.loadFile(resource, contentLoader.fhirVersion()));
466
587
  cs.sourcePackage = contentLoader.pid();
467
- const existing = cp.codeSystems.get(cs.url);
468
- if (!existing || cs.isMoreRecent(existing)) {
469
- cp.codeSystems.set(cs.url, cs);
470
- }
471
- if (cs.version) {
472
- cp.codeSystems.set(cs.vurl, cs);
473
- }
588
+ cp.codeSystems.push(cs);
474
589
  csc++;
475
590
  }
476
591
  this.codeSystemProviders.push(cp);
@@ -686,10 +801,12 @@ class Library {
686
801
  }
687
802
 
688
803
 
804
+ provider.codeSystemProviders = this.codeSystemProviders;
805
+ provider.context = context;
689
806
  for (const cp of this.codeSystemProviders) {
690
- const csMap = await cp.listCodeSystems(fhirVersion, context);
691
- for (const [key, value] of csMap) {
692
- provider.codeSystems.set(key, value);
807
+ const csList = await cp.listCodeSystems(fhirVersion, context);
808
+ for (const cs of csList) {
809
+ provider.addCodeSystem(cs);
693
810
  }
694
811
  }
695
812
  // Don't clone valueSetProviders yet - we'll build it with correct order
@@ -0,0 +1,236 @@
1
+ # OCL Integration in FHIRsmith
2
+
3
+ ## Overview
4
+ The `tx/ocl` module integrates [Open Concept Lab (OCL)](https://openconceptlab.org/) as a terminology source inside FHIRsmith.
5
+
6
+ It provides adapters/providers for:
7
+
8
+ - `CodeSystem` from OCL Sources
9
+ - `ValueSet` from OCL Collections
10
+ - `ConceptMap` from OCL Mappings
11
+
12
+ In FHIRsmith terms, these providers are loaded by `tx/library.js` when a source entry starts with `ocl:`. OCL metadata is discovered from OCL APIs, and heavy content (concept lists/expansions) is loaded lazily and warmed in background jobs.
13
+
14
+ ## Architecture
15
+ ### Main modules
16
+ - `cs-ocl.js`
17
+ - `OCLCodeSystemProvider`: discovers OCL sources and publishes CodeSystem metadata.
18
+ - `OCLSourceCodeSystemFactory`: creates runtime CodeSystem providers with shared caches, cold-cache hydration, and background full-load jobs.
19
+ - `OCLSourceCodeSystemProvider`: runtime concept lookup/filter/search behavior for one source.
20
+ - `vs-ocl.js`
21
+ - `OCLValueSetProvider`: discovers OCL collections, resolves compose includes, serves ValueSet metadata, and builds cached expansions in background.
22
+ - `cm-ocl.js`
23
+ - `OCLConceptMapProvider`: resolves OCL mappings for fetch/search/translation candidate discovery.
24
+
25
+ ### Supporting modules under `tx/ocl`
26
+ - `http/client.js`: Axios client creation, base URL normalization, token auth header (`Token` or `Bearer`).
27
+ - `http/pagination.js`: OCL pagination helper (`results/items/data`, `next`, page mode fallback).
28
+ - `cache/cache-paths.js`: cold-cache directories and canonical URL to file path mapping.
29
+ - `cache/cache-utils.js`: cache directory creation, file age detection, friendly age formatting.
30
+ - `fingerprint/fingerprint.js`: deterministic SHA-256 fingerprints for CodeSystem concepts and ValueSet expansions.
31
+ - `jobs/background-queue.js`: singleton keyed queue, size-priority ordering, max concurrency = 2, heartbeat logging every 30s.
32
+ - `mappers/concept-mapper.js`: maps OCL concept payloads to internal concept context shape.
33
+ - `model/concept-filter-context.js`: ranked filter result set used by CodeSystem filters.
34
+ - `shared/constants.js`: defaults and constants (`PAGE_SIZE`, cache freshness window, etc.).
35
+ - `shared/patches.js`:
36
+ - patches search worker so `CodeSystem?url=...&code=...` on OCL resources only returns matching concept subtree.
37
+ - patches `TxParameters.hashSource()` to include `filter` for expansion cache key differentiation.
38
+
39
+ ## Runtime flow
40
+ ### Metadata discovery
41
+ CodeSystems (`cs-ocl.js`):
42
+ - discover orgs via `/orgs/`
43
+ - for each org, discover sources via `/orgs/{org}/sources/`
44
+ - fallback to `/sources/` if org listing is unavailable
45
+
46
+ ValueSets (`vs-ocl.js`):
47
+ - discover orgs via `/orgs/`
48
+ - discover collections via `/orgs/{org}/collections/`
49
+ - fallback to `/collections/`
50
+
51
+ ConceptMaps (`cm-ocl.js`):
52
+ - fetch by id via `/mappings/{id}/`
53
+ - search via `/mappings/` or `/orgs/{org}/mappings/`
54
+
55
+ ### Lazy loading
56
+ - CodeSystem concepts are not fully loaded at metadata discovery time.
57
+ - ValueSet expansions are not built inline by default.
58
+ - Missing concept/page/expansion data triggers background warm-up scheduling.
59
+
60
+ ### Cold cache (disk)
61
+ - Base folder: `data/terminology-cache/ocl`
62
+ - CodeSystems: `data/terminology-cache/ocl/codesystems`
63
+ - ValueSets: `data/terminology-cache/ocl/valuesets`
64
+
65
+ On startup/initialization:
66
+ - CodeSystem factory hydrates concept cache from cold cache file if present.
67
+ - ValueSet provider loads cached expansions from cold cache files.
68
+
69
+ Corrupt cache handling:
70
+ - JSON parse/read errors are logged and skipped; provider continues without crashing.
71
+
72
+ ### Hot cache (memory)
73
+ - CodeSystems: shared in-memory concept/page caches per factory.
74
+ - ValueSets: in-memory expansion cache keyed by ValueSet + params hash.
75
+ - ConceptMaps: in-memory map keyed by URL/version/id.
76
+
77
+ ### Background warm-up jobs
78
+ Queue behavior (`OCLBackgroundJobQueue`):
79
+ - singleton job key (skip duplicate key)
80
+ - max concurrency = `2`
81
+ - ordering by `jobSize` (smaller concept count first)
82
+ - heartbeat log every `30s`
83
+ - progress supports `{ processed, total }` and `%`
84
+
85
+ CodeSystem warm-up:
86
+ - skipped when cold-cache file age is `<= 1 hour`
87
+ - enqueued when stale/no cold cache
88
+ - loads all concept pages
89
+ - computes fingerprint from full concept content
90
+ - if fingerprint changed, replaces cold cache file
91
+
92
+ ValueSet warm-up:
93
+ - skipped when freshest cold cache age (file mtime or cached timestamp) is `<= 1 hour`
94
+ - enqueued when stale/no cold cache
95
+ - builds expansion by paging collection concepts
96
+ - computes expansion fingerprint
97
+ - if fingerprint changed, replaces cold cache file
98
+
99
+ ### Fingerprint/checksum strategy
100
+ - OCL source checksum is treated as informational only in `cs-ocl.js` comments.
101
+ - Cache replacement decisions use custom fingerprints from concept/expansion content.
102
+ - ValueSet cache validity also checks metadata signature and dependency checksums from referenced CodeSystems.
103
+
104
+ ### ValueSet expansion filtering
105
+ `vs-ocl.js` supports filtered concept retrieval through `valueSet.oclFetchConcepts(...)`:
106
+ - local baseline filtering (code/display/definition text contains)
107
+ - `SearchFilterText` token behavior
108
+ - remote query hint (`q`) generated from normalized filter tokens
109
+ - dedicated smaller page size for filtered calls (`FILTERED_CONCEPT_PAGE_SIZE`)
110
+
111
+ ### `/CodeSystem?url=...&code=...` behavior for OCL
112
+ `shared/patches.js` patches search worker to apply concept subtree filtering only for OCL-marked CodeSystems (extension URL `http://fhir.org/FHIRsmith/StructureDefinition/ocl-codesystem`).
113
+
114
+ ## Configuration
115
+ ## Activation in FHIRsmith
116
+ OCL is activated by adding an OCL source line in the TX library YAML (for example `data/library.yml`):
117
+
118
+ ```yaml
119
+ sources:
120
+ - ocl:https://oclapi2.ips.hsl.org.br
121
+ ```
122
+
123
+ `tx/library.js` loads this via `loadOcl()`.
124
+
125
+ ### Source syntax
126
+ `tx/library.js` parses:
127
+
128
+ ```text
129
+ ocl:<baseUrl>|org=<orgId>|token=<tokenOrAlias>|timeout=<ms>
130
+ ```
131
+
132
+ Parsed keys:
133
+ - `baseUrl` (required)
134
+ - `org` (optional)
135
+ - `token` (optional)
136
+ - `timeout` (optional positive number)
137
+
138
+ Examples:
139
+
140
+ ```yaml
141
+ sources:
142
+ - ocl:https://api.openconceptlab.org
143
+ - ocl:https://ocl.example.org|org=my-org
144
+ - ocl:https://ocl.example.org|org=my-org|token=my-ocl-token|timeout=45000
145
+ ```
146
+
147
+ ### Config value aliasing
148
+ `Library.resolveOclConfigValue()` can resolve symbolic values from top-level YAML `ocl:` object loaded into `this.oclConfig`.
149
+
150
+ Example pattern:
151
+
152
+ ```yaml
153
+ ocl:
154
+ ocl-base: https://ocl.example.org
155
+ ocl-token: Token abc123
156
+
157
+ sources:
158
+ - ocl:ocl-base|org=my-org|token=ocl-token
159
+ ```
160
+
161
+ ### Credentials and URLs
162
+ - Base URL is required (OCL API root).
163
+ - Token is optional, sent as `Authorization`:
164
+ - `Token <value>` if no prefix is provided
165
+ - preserved if already `Token ...` or `Bearer ...`
166
+
167
+ ### Enable/disable integration
168
+ - Enable by including at least one `ocl:` source entry in TX library YAML.
169
+ - Disable by removing/commenting those `ocl:` entries.
170
+ - `modules.tx.enabled` must be true in server config to expose TX endpoints.
171
+
172
+ ## Cache behavior details
173
+ ### Startup hydration
174
+ - CodeSystem cold cache is loaded when factory is created (`OCLSourceCodeSystemFactory` constructor path).
175
+ - ValueSet cold cache is loaded during `OCLValueSetProvider.initialize()`.
176
+
177
+ ### 1-hour freshness rule
178
+ - Fresh threshold constant: `COLD_CACHE_FRESHNESS_MS = 60 * 60 * 1000`.
179
+ - If cold cache age is `<= 1 hour`, warm-up scheduling is skipped.
180
+
181
+ ### Hot cache replacing cold cache
182
+ - On successful background refresh, new fingerprint is compared to previous cold-cache fingerprint.
183
+ - If changed, cold cache is overwritten with the refreshed in-memory state.
184
+
185
+ ## Operational notes
186
+ ### Logs to expect
187
+ Prefixes:
188
+ - `[OCL]` for CodeSystem/queue/general OCL flow
189
+ - `[OCL-ValueSet]` for ValueSet flow
190
+
191
+ Typical events:
192
+ - fetched org/source/collection counts
193
+ - cold cache loaded/saved
194
+ - warm-up skipped/enqueued/started/completed
195
+ - fingerprint unchanged/changed
196
+ - queue status + heartbeat snapshots
197
+
198
+ ### Troubleshooting hints
199
+ - If nothing loads, verify OCL base URL and org visibility.
200
+ - If cache never warms, check cold-cache timestamps and 1-hour rule.
201
+ - If searches by `code` behave unexpectedly, confirm OCL marker extension is present on CodeSystem resources.
202
+ - For ValueSet filter behavior, verify `filter` is included in request path through `TxParameters.hashSource` patch and that filter text normalizes as expected.
203
+
204
+ ### Known limitations (from current implementation)
205
+ - OCL checksums are not relied on for cache invalidation.
206
+ - Some source/collection discovery paths depend on OCL endpoint support and can fallback.
207
+ - Missing endpoints returning `404` in some concept fetch paths are treated as empty content for graceful degradation.
208
+
209
+ ## Developer notes
210
+ ### Where to extend
211
+ - Add/adjust OCL HTTP behavior: `tx/ocl/http/*`
212
+ - Add cache policy/path behavior: `tx/ocl/cache/*`
213
+ - Add queue policy: `tx/ocl/jobs/background-queue.js`
214
+ - Add mapping logic from OCL payloads: `tx/ocl/mappers/*`
215
+ - Add fingerprint strategy: `tx/ocl/fingerprint/*`
216
+ - Add provider-specific behavior:
217
+ - CodeSystem: `tx/ocl/cs-ocl.js`
218
+ - ValueSet: `tx/ocl/vs-ocl.js`
219
+ - ConceptMap: `tx/ocl/cm-ocl.js`
220
+
221
+ ### Ownership by concern
222
+ - HTTP access: `http/client.js`, `http/pagination.js`
223
+ - Disk cold cache and pathing: `cache/cache-paths.js`, `cache/cache-utils.js`
224
+ - Background jobs and scheduling policy: `jobs/background-queue.js`
225
+ - Fingerprints/checksums: `fingerprint/fingerprint.js` and provider compare logic
226
+ - Worker/runtime patches: `shared/patches.js`
227
+
228
+ ### Test strategy for future changes
229
+ - Keep provider tests deterministic by mocking OCL HTTP responses and cold-cache files.
230
+ - Validate stale/fresh transitions with file mtime control.
231
+ - Validate queue behavior with isolated static state reset between tests.
232
+ - Track coverage with:
233
+
234
+ ```bash
235
+ npm test -- --runInBand tests/ocl --coverage --collectCoverageFrom="tx/ocl/**/*.js"
236
+ ```
@@ -0,0 +1,32 @@
1
+ const path = require('path');
2
+
3
+ const CACHE_BASE_DIR = path.join(process.cwd(), 'data', 'terminology-cache', 'ocl');
4
+ const CACHE_CS_DIR = path.join(CACHE_BASE_DIR, 'codesystems');
5
+ const CACHE_VS_DIR = path.join(CACHE_BASE_DIR, 'valuesets');
6
+
7
+ function sanitizeFilename(text) {
8
+ if (!text || typeof text !== 'string') {
9
+ return 'unknown';
10
+ }
11
+ return text
12
+ .replace(/[^a-zA-Z0-9._-]/g, '_')
13
+ .replace(/_+/g, '_')
14
+ .substring(0, 200);
15
+ }
16
+
17
+ function getCacheFilePath(baseDir, canonicalUrl, version = null, paramsKey = null) {
18
+ const filename = sanitizeFilename(canonicalUrl)
19
+ + (version ? `_${sanitizeFilename(version)}` : '')
20
+ + (paramsKey && paramsKey !== 'default' ? `_p_${sanitizeFilename(paramsKey)}` : '')
21
+ + '.json';
22
+
23
+ return path.join(baseDir, filename);
24
+ }
25
+
26
+ module.exports = {
27
+ CACHE_BASE_DIR,
28
+ CACHE_CS_DIR,
29
+ CACHE_VS_DIR,
30
+ sanitizeFilename,
31
+ getCacheFilePath
32
+ };
@@ -0,0 +1,2 @@
1
+ module.exports = require('./cache-paths.cjs');
2
+
@@ -0,0 +1,43 @@
1
+ const fs = require('fs/promises');
2
+ const fsSync = require('fs');
3
+
4
+ async function ensureCacheDirectories(...dirs) {
5
+ for (const dir of dirs) {
6
+ if (!dir) {
7
+ continue;
8
+ }
9
+
10
+ try {
11
+ await fs.mkdir(dir, { recursive: true });
12
+ } catch (error) {
13
+ console.error('[OCL] Failed to create cache directory:', dir, error.message);
14
+ }
15
+ }
16
+ }
17
+
18
+ function getColdCacheAgeMs(cacheFilePath, logPrefix = '[OCL]') {
19
+ try {
20
+ const stats = fsSync.statSync(cacheFilePath);
21
+ if (!stats || !Number.isFinite(stats.mtimeMs)) {
22
+ return null;
23
+ }
24
+
25
+ return Math.max(0, Date.now() - stats.mtimeMs);
26
+ } catch (error) {
27
+ if (error && error.code !== 'ENOENT') {
28
+ console.error(`${logPrefix} Failed to inspect cold cache file ${cacheFilePath}: ${error.message}`);
29
+ }
30
+ return null;
31
+ }
32
+ }
33
+
34
+ function formatCacheAgeMinutes(ageMs) {
35
+ const minutes = Math.max(1, Math.round(ageMs / 60000));
36
+ return `${minutes} minute${minutes === 1 ? '' : 's'}`;
37
+ }
38
+
39
+ module.exports = {
40
+ ensureCacheDirectories,
41
+ getColdCacheAgeMs,
42
+ formatCacheAgeMinutes
43
+ };
@@ -0,0 +1,2 @@
1
+ module.exports = require('./cache-utils.cjs');
2
+